feat(plugin): add Claude Code plugin for host-uk framework

Core plugin providing:
- Skills: core CLI reference, PHP patterns, Go patterns
- Commands: /core:remember for context persistence
- Hooks:
  - PreToolUse: block dangerous commands (rm -rf, sed -i, grep -l |)
  - PreToolUse: enforce core CLI over raw go/php commands
  - PostToolUse: auto-format Go/PHP, check for debug statements
  - PostToolUse: warn about uncommitted work after git commit
  - PreCompact: save state to prevent amnesia after auto-compact
  - SessionStart: restore context from recent sessions (<3h)
- MCP: core CLI server integration

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-01-31 10:27:04 +00:00
parent 96eaed507c
commit a93cc3540a
23 changed files with 3294 additions and 0 deletions

View file

@ -0,0 +1,134 @@
# i18n Package Refactor Design
## Goal
Refactor pkg/i18n to be extensible without breaking changes in future. Based on Gemini review recommendations.
## File Structure
### Renamed/Merged
| Current | New | Reason |
|---------|-----|--------|
| `interfaces.go` | `types.go` | Contains types, not interfaces |
| `mutate.go` | `loader.go` | Loads/flattens JSON |
| `actions.go` | `hooks.go` | Missing key callbacks |
| `checks.go` | (merge into loader.go) | Loading helpers |
| `mode.go` | (merge into types.go) | Just one type |
### New Files
| File | Purpose |
|------|---------|
| `handler.go` | KeyHandler interface + built-in handlers |
| `context.go` | TranslationContext + C() helper |
### Unchanged
`grammar.go`, `language.go`, `localise.go`, `debug.go`, `numbers.go`, `time.go`, `i18n.go`, `intents.go`, `compose.go`, `transform.go`
## Interfaces
### KeyHandler
```go
// KeyHandler processes translation keys before standard lookup.
type KeyHandler interface {
Match(key string) bool
Handle(key string, args []any, next func() string) string
}
```
Built-in handlers:
- `LabelHandler` - `i18n.label.*` → "Status:"
- `ProgressHandler` - `i18n.progress.*` → "Building..."
- `CountHandler` - `i18n.count.*` → "5 files"
- `NumericHandler` - `i18n.numeric.*` → formatted numbers
- `DoneHandler` - `i18n.done.*` → "File deleted"
- `FailHandler` - `i18n.fail.*` → "Failed to delete file"
### Loader
```go
// Loader provides translation data to the Service.
type Loader interface {
Load(lang string) (map[string]Message, *GrammarData, error)
Languages() []string
}
```
Built-in: `FSLoader` for embedded/filesystem JSON.
### TranslationContext
```go
type TranslationContext struct {
Context string
Gender string
Formality Formality
Extra map[string]any
}
func C(context string) *TranslationContext
```
## Service Changes
```go
type Service struct {
loader Loader
messages map[string]map[string]Message
grammar map[string]*GrammarData
currentLang string
fallbackLang string
formality Formality
mode Mode
debug bool
handlers []KeyHandler
mu sync.RWMutex
}
```
### Constructors
```go
func New() (*Service, error)
func NewWithLoader(loader Loader, opts ...Option) (*Service, error)
type Option func(*Service)
func WithDefaultHandlers() Option
func WithFallback(lang string) Option
func WithFormality(f Formality) Option
```
### T() Flow
1. Parse args → extract Context, Subject, data
2. Run handler chain (each can handle or call next)
3. Standard lookup with context suffix fallback
## Public API
### Keep
- `T(key, args...)`, `Raw(key, args...)`
- `S(noun, value)` - Subject builder
- `SetLanguage()`, `CurrentLanguage()`, `SetMode()`, `CurrentMode()`
- `SetFormality()`, `SetDebug()`, `Direction()`, `IsRTL()`
- Grammar: `PastTense()`, `Gerund()`, `Pluralize()`, `Article()`, `Title()`, `Label()`, `Progress()`
### Add
- `C(context)` - Context builder
- `NewWithLoader()` - Custom loader support
- `AddHandler()`, `PrependHandler()` - Custom handlers
### Remove (No Aliases)
- `NewSubject()` - use `S()`
- `N()` - use `T("i18n.numeric.*")`
## Breaking Changes
- Constructor signature changes
- Internal file reorganisation
- No backwards compatibility layer
## Implementation Order
1. Create new files (types.go, handler.go, loader.go, context.go, hooks.go)
2. Move types from interfaces.go → types.go
3. Implement Loader interface + FSLoader
4. Implement KeyHandler interface + built-in handlers
5. Implement TranslationContext
6. Update Service struct + constructors
7. Update T() to use handler chain
8. Update package-level functions in i18n.go
9. Delete old files
10. Update tests

File diff suppressed because it is too large Load diff

193
pkg/cli/command.go Normal file
View file

@ -0,0 +1,193 @@
package cli
import (
"github.com/spf13/cobra"
)
// ─────────────────────────────────────────────────────────────────────────────
// Command Type Re-export
// ─────────────────────────────────────────────────────────────────────────────
// Command is the cobra command type.
// Re-exported for convenience so packages don't need to import cobra directly.
type Command = cobra.Command
// ─────────────────────────────────────────────────────────────────────────────
// Command Builders
// ─────────────────────────────────────────────────────────────────────────────
// NewCommand creates a new command with a RunE handler.
// This is the standard way to create commands that may return errors.
//
// cmd := cli.NewCommand("build", "Build the project", "", func(cmd *cli.Command, args []string) error {
// // Build logic
// return nil
// })
func NewCommand(use, short, long string, run func(cmd *Command, args []string) error) *Command {
cmd := &Command{
Use: use,
Short: short,
RunE: run,
}
if long != "" {
cmd.Long = long
}
return cmd
}
// NewGroup creates a new command group (no RunE).
// Use this for parent commands that only contain subcommands.
//
// devCmd := cli.NewGroup("dev", "Development commands", "")
// devCmd.AddCommand(buildCmd, testCmd)
func NewGroup(use, short, long string) *Command {
cmd := &Command{
Use: use,
Short: short,
}
if long != "" {
cmd.Long = long
}
return cmd
}
// NewRun creates a new command with a simple Run handler (no error return).
// Use when the command cannot fail.
//
// cmd := cli.NewRun("version", "Show version", "", func(cmd *cli.Command, args []string) {
// cli.Println("v1.0.0")
// })
func NewRun(use, short, long string, run func(cmd *Command, args []string)) *Command {
cmd := &Command{
Use: use,
Short: short,
Run: run,
}
if long != "" {
cmd.Long = long
}
return cmd
}
// ─────────────────────────────────────────────────────────────────────────────
// Flag Helpers
// ─────────────────────────────────────────────────────────────────────────────
// StringFlag adds a string flag to a command.
// The value will be stored in the provided pointer.
//
// var output string
// cli.StringFlag(cmd, &output, "output", "o", "", "Output file path")
func StringFlag(cmd *Command, ptr *string, name, short, def, usage string) {
if short != "" {
cmd.Flags().StringVarP(ptr, name, short, def, usage)
} else {
cmd.Flags().StringVar(ptr, name, def, usage)
}
}
// BoolFlag adds a boolean flag to a command.
// The value will be stored in the provided pointer.
//
// var verbose bool
// cli.BoolFlag(cmd, &verbose, "verbose", "v", false, "Enable verbose output")
func BoolFlag(cmd *Command, ptr *bool, name, short string, def bool, usage string) {
if short != "" {
cmd.Flags().BoolVarP(ptr, name, short, def, usage)
} else {
cmd.Flags().BoolVar(ptr, name, def, usage)
}
}
// IntFlag adds an integer flag to a command.
// The value will be stored in the provided pointer.
//
// var count int
// cli.IntFlag(cmd, &count, "count", "n", 10, "Number of items")
func IntFlag(cmd *Command, ptr *int, name, short string, def int, usage string) {
if short != "" {
cmd.Flags().IntVarP(ptr, name, short, def, usage)
} else {
cmd.Flags().IntVar(ptr, name, def, usage)
}
}
// StringSliceFlag adds a string slice flag to a command.
// The value will be stored in the provided pointer.
//
// var tags []string
// cli.StringSliceFlag(cmd, &tags, "tag", "t", nil, "Tags to apply")
func StringSliceFlag(cmd *Command, ptr *[]string, name, short string, def []string, usage string) {
if short != "" {
cmd.Flags().StringSliceVarP(ptr, name, short, def, usage)
} else {
cmd.Flags().StringSliceVar(ptr, name, def, usage)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Persistent Flag Helpers
// ─────────────────────────────────────────────────────────────────────────────
// PersistentStringFlag adds a persistent string flag (inherited by subcommands).
func PersistentStringFlag(cmd *Command, ptr *string, name, short, def, usage string) {
if short != "" {
cmd.PersistentFlags().StringVarP(ptr, name, short, def, usage)
} else {
cmd.PersistentFlags().StringVar(ptr, name, def, usage)
}
}
// PersistentBoolFlag adds a persistent boolean flag (inherited by subcommands).
func PersistentBoolFlag(cmd *Command, ptr *bool, name, short string, def bool, usage string) {
if short != "" {
cmd.PersistentFlags().BoolVarP(ptr, name, short, def, usage)
} else {
cmd.PersistentFlags().BoolVar(ptr, name, def, usage)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Command Configuration
// ─────────────────────────────────────────────────────────────────────────────
// WithArgs sets the Args validation function for a command.
// Returns the command for chaining.
//
// cmd := cli.NewCommand("build", "Build", "", run).WithArgs(cobra.ExactArgs(1))
func WithArgs(cmd *Command, args cobra.PositionalArgs) *Command {
cmd.Args = args
return cmd
}
// WithExample sets the Example field for a command.
// Returns the command for chaining.
func WithExample(cmd *Command, example string) *Command {
cmd.Example = example
return cmd
}
// ExactArgs returns a PositionalArgs that accepts exactly N arguments.
func ExactArgs(n int) cobra.PositionalArgs {
return cobra.ExactArgs(n)
}
// MinimumNArgs returns a PositionalArgs that accepts minimum N arguments.
func MinimumNArgs(n int) cobra.PositionalArgs {
return cobra.MinimumNArgs(n)
}
// MaximumNArgs returns a PositionalArgs that accepts maximum N arguments.
func MaximumNArgs(n int) cobra.PositionalArgs {
return cobra.MaximumNArgs(n)
}
// NoArgs returns a PositionalArgs that accepts no arguments.
func NoArgs() cobra.PositionalArgs {
return cobra.NoArgs
}
// ArbitraryArgs returns a PositionalArgs that accepts any arguments.
func ArbitraryArgs() cobra.PositionalArgs {
return cobra.ArbitraryArgs
}

116
pkg/cli/errors.go Normal file
View file

@ -0,0 +1,116 @@
package cli
import (
"errors"
"fmt"
"os"
"github.com/host-uk/core/pkg/i18n"
)
// ─────────────────────────────────────────────────────────────────────────────
// Error Creation (replace fmt.Errorf)
// ─────────────────────────────────────────────────────────────────────────────
// Err creates a new error from a format string.
// This is a direct replacement for fmt.Errorf.
func Err(format string, args ...any) error {
return fmt.Errorf(format, args...)
}
// Wrap wraps an error with a message.
// Returns nil if err is nil.
//
// return cli.Wrap(err, "load config") // "load config: <original error>"
func Wrap(err error, msg string) error {
if err == nil {
return nil
}
return fmt.Errorf("%s: %w", msg, err)
}
// WrapVerb wraps an error using i18n grammar for "Failed to verb subject".
// Uses the i18n.ActionFailed function for proper grammar composition.
// Returns nil if err is nil.
//
// return cli.WrapVerb(err, "load", "config") // "Failed to load config: <original error>"
func WrapVerb(err error, verb, subject string) error {
if err == nil {
return nil
}
msg := i18n.ActionFailed(verb, subject)
return fmt.Errorf("%s: %w", msg, err)
}
// WrapAction wraps an error using i18n grammar for "Failed to verb".
// Uses the i18n.ActionFailed function for proper grammar composition.
// Returns nil if err is nil.
//
// return cli.WrapAction(err, "connect") // "Failed to connect: <original error>"
func WrapAction(err error, verb string) error {
if err == nil {
return nil
}
msg := i18n.ActionFailed(verb, "")
return fmt.Errorf("%s: %w", msg, err)
}
// ─────────────────────────────────────────────────────────────────────────────
// Error Helpers
// ─────────────────────────────────────────────────────────────────────────────
// Is reports whether any error in err's tree matches target.
// This is a re-export of errors.Is for convenience.
func Is(err, target error) bool {
return errors.Is(err, target)
}
// As finds the first error in err's tree that matches target.
// This is a re-export of errors.As for convenience.
func As(err error, target any) bool {
return errors.As(err, target)
}
// Join returns an error that wraps the given errors.
// This is a re-export of errors.Join for convenience.
func Join(errs ...error) error {
return errors.Join(errs...)
}
// ─────────────────────────────────────────────────────────────────────────────
// Fatal Functions (print and exit)
// ─────────────────────────────────────────────────────────────────────────────
// Fatalf prints a formatted error message and exits with code 1.
func Fatalf(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
fmt.Println(ErrorStyle.Render(SymbolCross + " " + msg))
os.Exit(1)
}
// FatalWrap prints a wrapped error message and exits with code 1.
// Does nothing if err is nil.
//
// cli.FatalWrap(err, "load config") // Prints "✗ load config: <error>" and exits
func FatalWrap(err error, msg string) {
if err == nil {
return
}
fullMsg := fmt.Sprintf("%s: %v", msg, err)
fmt.Println(ErrorStyle.Render(SymbolCross + " " + fullMsg))
os.Exit(1)
}
// FatalWrapVerb prints a wrapped error using i18n grammar and exits with code 1.
// Does nothing if err is nil.
//
// cli.FatalWrapVerb(err, "load", "config") // Prints "✗ Failed to load config: <error>" and exits
func FatalWrapVerb(err error, verb, subject string) {
if err == nil {
return
}
msg := i18n.ActionFailed(verb, subject)
fullMsg := fmt.Sprintf("%s: %v", msg, err)
fmt.Println(ErrorStyle.Render(SymbolCross + " " + fullMsg))
os.Exit(1)
}

176
pkg/cli/output.go Normal file
View file

@ -0,0 +1,176 @@
package cli
import (
"fmt"
"github.com/charmbracelet/lipgloss"
"github.com/host-uk/core/pkg/i18n"
)
// ─────────────────────────────────────────────────────────────────────────────
// Style Namespace
// ─────────────────────────────────────────────────────────────────────────────
// Styles provides namespaced access to CLI styles.
// Usage: cli.Style.Dim.Render("text"), cli.Style.Success.Render("done")
var Style = struct {
// Text styles
Dim lipgloss.Style
Muted lipgloss.Style
Bold lipgloss.Style
Value lipgloss.Style
Accent lipgloss.Style
Code lipgloss.Style
Key lipgloss.Style
Number lipgloss.Style
Link lipgloss.Style
Header lipgloss.Style
Title lipgloss.Style
Stage lipgloss.Style
PrNum lipgloss.Style
AccentL lipgloss.Style
// Status styles
Success lipgloss.Style
Error lipgloss.Style
Warning lipgloss.Style
Info lipgloss.Style
// Git styles
Dirty lipgloss.Style
Ahead lipgloss.Style
Behind lipgloss.Style
Clean lipgloss.Style
Conflict lipgloss.Style
// Repo name style
Repo lipgloss.Style
// Coverage styles
CoverageHigh lipgloss.Style
CoverageMed lipgloss.Style
CoverageLow lipgloss.Style
// Priority styles
PriorityHigh lipgloss.Style
PriorityMedium lipgloss.Style
PriorityLow lipgloss.Style
// Severity styles
SeverityCritical lipgloss.Style
SeverityHigh lipgloss.Style
SeverityMedium lipgloss.Style
SeverityLow lipgloss.Style
// Status indicator styles
StatusPending lipgloss.Style
StatusRunning lipgloss.Style
StatusSuccess lipgloss.Style
StatusError lipgloss.Style
StatusWarning lipgloss.Style
// Deploy styles
DeploySuccess lipgloss.Style
DeployPending lipgloss.Style
DeployFailed lipgloss.Style
// Box styles
Box lipgloss.Style
BoxHeader lipgloss.Style
ErrorBox lipgloss.Style
SuccessBox lipgloss.Style
}{
// Text styles
Dim: DimStyle,
Muted: MutedStyle,
Bold: BoldStyle,
Value: ValueStyle,
Accent: AccentStyle,
Code: CodeStyle,
Key: KeyStyle,
Number: NumberStyle,
Link: LinkStyle,
Header: HeaderStyle,
Title: TitleStyle,
Stage: StageStyle,
PrNum: PrNumberStyle,
AccentL: AccentLabelStyle,
// Status styles
Success: SuccessStyle,
Error: ErrorStyle,
Warning: WarningStyle,
Info: InfoStyle,
// Git styles
Dirty: GitDirtyStyle,
Ahead: GitAheadStyle,
Behind: GitBehindStyle,
Clean: GitCleanStyle,
Conflict: GitConflictStyle,
// Repo name style
Repo: RepoNameStyle,
// Coverage styles
CoverageHigh: CoverageHighStyle,
CoverageMed: CoverageMedStyle,
CoverageLow: CoverageLowStyle,
// Priority styles
PriorityHigh: PriorityHighStyle,
PriorityMedium: PriorityMediumStyle,
PriorityLow: PriorityLowStyle,
// Severity styles
SeverityCritical: SeverityCriticalStyle,
SeverityHigh: SeverityHighStyle,
SeverityMedium: SeverityMediumStyle,
SeverityLow: SeverityLowStyle,
// Status indicator styles
StatusPending: StatusPendingStyle,
StatusRunning: StatusRunningStyle,
StatusSuccess: StatusSuccessStyle,
StatusError: StatusErrorStyle,
StatusWarning: StatusWarningStyle,
// Deploy styles
DeploySuccess: DeploySuccessStyle,
DeployPending: DeployPendingStyle,
DeployFailed: DeployFailedStyle,
// Box styles
Box: BoxStyle,
BoxHeader: BoxHeaderStyle,
ErrorBox: ErrorBoxStyle,
SuccessBox: SuccessBoxStyle,
}
// ─────────────────────────────────────────────────────────────────────────────
// Core Output Functions
// ─────────────────────────────────────────────────────────────────────────────
// Line translates a key via i18n.T and prints with newline.
// If no key is provided, prints an empty line.
//
// cli.Line("i18n.progress.check") // prints "Checking...\n"
// cli.Line("cmd.dev.ci.short") // prints translated text + \n
// cli.Line("greeting", map[string]any{"Name": "World"}) // with args
// cli.Line("") // prints empty line
func Line(key string, args ...any) {
if key == "" {
fmt.Println()
return
}
fmt.Println(i18n.T(key, args...))
}
// ─────────────────────────────────────────────────────────────────────────────
// Input Functions
// ─────────────────────────────────────────────────────────────────────────────
// Scanln reads from stdin, similar to fmt.Scanln.
func Scanln(a ...any) (int, error) {
return fmt.Scanln(a...)
}

88
pkg/cli/strings.go Normal file
View file

@ -0,0 +1,88 @@
package cli
import (
"fmt"
"github.com/charmbracelet/lipgloss"
)
// ─────────────────────────────────────────────────────────────────────────────
// String Formatting (replace fmt.Sprintf)
// ─────────────────────────────────────────────────────────────────────────────
// Sprintf formats a string.
// This is a direct replacement for fmt.Sprintf.
func Sprintf(format string, args ...any) string {
return fmt.Sprintf(format, args...)
}
// Sprint formats using the default formats for its operands.
// This is a direct replacement for fmt.Sprint.
func Sprint(args ...any) string {
return fmt.Sprint(args...)
}
// ─────────────────────────────────────────────────────────────────────────────
// Styled String Functions
// ─────────────────────────────────────────────────────────────────────────────
// Styled returns text formatted with a style.
// Example: cli.Styled(cli.Style.Success, "Done!")
func Styled(style lipgloss.Style, text string) string {
return style.Render(text)
}
// Styledf returns formatted text with a style.
// Example: cli.Styledf(cli.Style.Success, "Processed %d items", count)
func Styledf(style lipgloss.Style, format string, args ...any) string {
return style.Render(fmt.Sprintf(format, args...))
}
// ─────────────────────────────────────────────────────────────────────────────
// Pre-styled Formatting Functions
// ─────────────────────────────────────────────────────────────────────────────
// SuccessStr returns a success-styled string with checkmark.
func SuccessStr(msg string) string {
return SuccessStyle.Render(SymbolCheck + " " + msg)
}
// ErrorStr returns an error-styled string with cross.
func ErrorStr(msg string) string {
return ErrorStyle.Render(SymbolCross + " " + msg)
}
// WarningStr returns a warning-styled string with warning symbol.
func WarningStr(msg string) string {
return WarningStyle.Render(SymbolWarning + " " + msg)
}
// InfoStr returns an info-styled string with info symbol.
func InfoStr(msg string) string {
return InfoStyle.Render(SymbolInfo + " " + msg)
}
// DimStr returns a dim-styled string.
func DimStr(msg string) string {
return DimStyle.Render(msg)
}
// BoldStr returns a bold-styled string.
func BoldStr(msg string) string {
return BoldStyle.Render(msg)
}
// ─────────────────────────────────────────────────────────────────────────────
// Numeric Formatting
// ─────────────────────────────────────────────────────────────────────────────
// Itoa converts an integer to a string.
// This is a convenience function similar to strconv.Itoa.
func Itoa(n int) string {
return fmt.Sprintf("%d", n)
}
// Itoa64 converts an int64 to a string.
func Itoa64(n int64) string {
return fmt.Sprintf("%d", n)
}

View file

@ -0,0 +1,36 @@
---
name: remember
description: Save a fact or decision to context for persistence across compacts
args: <fact to remember>
---
# Remember Context
Save the provided fact to `~/.claude/sessions/context.json`.
## Usage
```
/core:remember Use Action pattern not Service
/core:remember User prefers UK English
/core:remember RFC: minimal state in pre-compact hook
```
## Action
Run this command to save the fact:
```bash
~/.claude/plugins/cache/core/scripts/capture-context.sh "<fact>" "user"
```
Or if running from the plugin directory:
```bash
"${CLAUDE_PLUGIN_ROOT}/scripts/capture-context.sh" "<fact>" "user"
```
The fact will be:
- Stored in context.json (max 20 items)
- Included in pre-compact snapshots
- Auto-cleared after 3 hours of inactivity

102
plugin/hooks/prefer-core.sh Executable file
View file

@ -0,0 +1,102 @@
#!/bin/bash
# PreToolUse hook: Block dangerous commands, enforce core CLI
#
# BLOCKS:
# - Raw go commands (use core go *)
# - Destructive grep patterns (sed -i, xargs rm, etc.)
# - Mass file operations (rm -rf, mv/cp with wildcards)
# - Any sed outside of safe patterns
#
# This prevents "efficient shortcuts" that nuke codebases
read -r input
command=$(echo "$input" | jq -r '.tool_input.command // empty')
# === HARD BLOCKS - Never allow these ===
# Block rm -rf, rm -r (except for known safe paths like node_modules, vendor, .cache)
if echo "$command" | grep -qE 'rm\s+(-[a-zA-Z]*r[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*r|--recursive)'; then
# Allow only specific safe directories
if ! echo "$command" | grep -qE 'rm\s+(-rf|-r)\s+(node_modules|vendor|\.cache|dist|build|__pycache__|\.pytest_cache|/tmp/)'; then
echo '{"decision": "block", "message": "BLOCKED: Recursive delete is not allowed. Delete files individually or ask the user to run this command."}'
exit 0
fi
fi
# Block mv/cp with wildcards (mass file moves)
if echo "$command" | grep -qE '(mv|cp)\s+.*\*'; then
echo '{"decision": "block", "message": "BLOCKED: Mass file move/copy with wildcards is not allowed. Move files individually."}'
exit 0
fi
# Block xargs with rm, mv, cp (mass operations)
if echo "$command" | grep -qE 'xargs\s+.*(rm|mv|cp)'; then
echo '{"decision": "block", "message": "BLOCKED: xargs with file operations is not allowed. Too risky for mass changes."}'
exit 0
fi
# Block find -exec with rm, mv, cp
if echo "$command" | grep -qE 'find\s+.*-exec\s+.*(rm|mv|cp)'; then
echo '{"decision": "block", "message": "BLOCKED: find -exec with file operations is not allowed. Too risky for mass changes."}'
exit 0
fi
# Block ALL sed -i (in-place editing)
if echo "$command" | grep -qE 'sed\s+(-[a-zA-Z]*i|--in-place)'; then
echo '{"decision": "block", "message": "BLOCKED: sed -i (in-place edit) is never allowed. Use the Edit tool for file changes."}'
exit 0
fi
# Block sed piped to file operations
if echo "$command" | grep -qE 'sed.*\|.*tee|sed.*>'; then
echo '{"decision": "block", "message": "BLOCKED: sed with file output is not allowed. Use the Edit tool for file changes."}'
exit 0
fi
# Block grep with -l piped to xargs/rm/sed (the classic codebase nuke pattern)
if echo "$command" | grep -qE 'grep\s+.*-l.*\|'; then
echo '{"decision": "block", "message": "BLOCKED: grep -l piped to other commands is the classic codebase nuke pattern. Not allowed."}'
exit 0
fi
# Block perl -i, awk with file redirection (sed alternatives)
if echo "$command" | grep -qE 'perl\s+-[a-zA-Z]*i|awk.*>'; then
echo '{"decision": "block", "message": "BLOCKED: In-place file editing with perl/awk is not allowed. Use the Edit tool."}'
exit 0
fi
# === REQUIRE CORE CLI ===
# Block raw go commands
case "$command" in
"go test"*|"go build"*|"go fmt"*|"go mod tidy"*|"go vet"*|"go run"*)
echo '{"decision": "block", "message": "Use `core go test`, `core build`, `core go fmt --fix`, etc. Raw go commands are not allowed."}'
exit 0
;;
"go "*)
# Other go commands - warn but allow
echo '{"decision": "block", "message": "Prefer `core go *` commands. If core does not have this command, ask the user."}'
exit 0
;;
esac
# Block raw php commands
case "$command" in
"php artisan serve"*|"./vendor/bin/pest"*|"./vendor/bin/pint"*|"./vendor/bin/phpstan"*)
echo '{"decision": "block", "message": "Use `core php dev`, `core php test`, `core php fmt`, `core php analyse`. Raw php commands are not allowed."}'
exit 0
;;
"composer test"*|"composer lint"*)
echo '{"decision": "block", "message": "Use `core php test` or `core php fmt`. Raw composer commands are not allowed."}'
exit 0
;;
esac
# Block golangci-lint directly
if echo "$command" | grep -qE '^golangci-lint'; then
echo '{"decision": "block", "message": "Use `core go lint` instead of golangci-lint directly."}'
exit 0
fi
# === APPROVED ===
echo '{"decision": "approve"}'

109
plugin/plugin.json Normal file
View file

@ -0,0 +1,109 @@
{
"name": "core",
"version": "1.0.0",
"description": "Host UK unified framework - Go CLI, PHP framework, multi-repo management",
"dependencies": [
"superpowers@claude-plugins-official"
],
"skills": [
{
"name": "core",
"path": "skills/core.md",
"description": "Use when working in host-uk repositories. Provides core CLI command reference."
},
{
"name": "core-php",
"path": "skills/php.md",
"description": "Use when creating PHP modules, services, or actions in core-* packages."
},
{
"name": "core-go",
"path": "skills/go.md",
"description": "Use when creating Go packages or extending the core CLI."
}
],
"commands": [
{
"name": "remember",
"path": "commands/remember.md",
"description": "Save a fact or decision to context"
}
],
"hooks": {
"SessionStart": [
{
"matcher": "*",
"script": "scripts/session-start.sh",
"description": "Check for recent session state on startup"
}
],
"PreCompact": [
{
"matcher": "*",
"script": "scripts/pre-compact.sh",
"description": "Save state before auto-compact to prevent amnesia"
}
],
"PreToolUse": [
{
"matcher": "Bash",
"script": "hooks/prefer-core.sh",
"description": "Suggest core CLI instead of raw go/php commands"
},
{
"matcher": "Write",
"script": "scripts/block-docs.sh",
"description": "Block random .md files, keep docs consolidated"
},
{
"matcher": "Edit",
"script": "scripts/suggest-compact.sh",
"description": "Suggest /compact at logical intervals"
},
{
"matcher": "Write",
"script": "scripts/suggest-compact.sh",
"description": "Suggest /compact at logical intervals"
}
],
"PostToolUse": [
{
"matcher": "Edit",
"script": "scripts/php-format.sh",
"description": "Auto-format PHP files after edits"
},
{
"matcher": "Edit",
"script": "scripts/go-format.sh",
"description": "Auto-format Go files after edits"
},
{
"matcher": "Edit",
"script": "scripts/check-debug.sh",
"description": "Warn about debug statements (dd, dump, fmt.Println)"
},
{
"matcher": "Bash",
"script": "scripts/pr-created.sh",
"description": "Log PR URL after creation"
},
{
"matcher": "Bash",
"script": "scripts/extract-actionables.sh",
"description": "Extract actionables from core CLI output"
},
{
"matcher": "Bash",
"script": "scripts/post-commit-check.sh",
"description": "Warn about uncommitted work after git commit"
}
]
},
"mcp": {
"core": {
"command": "core",
"args": ["mcp", "serve"],
"description": "Core CLI MCP server for multi-repo operations"
}
}
}

27
plugin/scripts/block-docs.sh Executable file
View file

@ -0,0 +1,27 @@
#!/bin/bash
# Block creation of random .md files - keeps docs consolidated
read -r input
FILE_PATH=$(echo "$input" | jq -r '.tool_input.file_path // empty')
if [[ -n "$FILE_PATH" ]]; then
# Allow known documentation files
case "$FILE_PATH" in
*README.md|*CLAUDE.md|*AGENTS.md|*CONTRIBUTING.md|*CHANGELOG.md|*LICENSE.md)
echo "$input"
exit 0
;;
# Allow docs/ directory
*/docs/*.md|*/docs/**/*.md)
echo "$input"
exit 0
;;
# Block other .md files
*.md)
echo '{"decision": "block", "message": "Use README.md or docs/ for documentation. Random .md files clutter the repo."}'
exit 0
;;
esac
fi
echo "$input"

View file

@ -0,0 +1,44 @@
#!/bin/bash
# Capture context facts from tool output or conversation
# Called by PostToolUse hooks to extract actionable items
#
# Stores in ~/.claude/sessions/context.json as:
# [{"fact": "...", "source": "core go qa", "ts": 1234567890}, ...]
CONTEXT_FILE="${HOME}/.claude/sessions/context.json"
TIMESTAMP=$(date '+%s')
THREE_HOURS=10800
mkdir -p "${HOME}/.claude/sessions"
# Initialize if missing or stale
if [[ -f "$CONTEXT_FILE" ]]; then
FIRST_TS=$(jq -r '.[0].ts // 0' "$CONTEXT_FILE" 2>/dev/null)
NOW=$(date '+%s')
AGE=$((NOW - FIRST_TS))
if [[ $AGE -gt $THREE_HOURS ]]; then
echo "[]" > "$CONTEXT_FILE"
fi
else
echo "[]" > "$CONTEXT_FILE"
fi
# Read input (fact and source passed as args or stdin)
FACT="${1:-}"
SOURCE="${2:-manual}"
if [[ -z "$FACT" ]]; then
# Try reading from stdin
read -r FACT
fi
if [[ -n "$FACT" ]]; then
# Append to context (keep last 20 items)
jq --arg fact "$FACT" --arg source "$SOURCE" --argjson ts "$TIMESTAMP" \
'. + [{"fact": $fact, "source": $source, "ts": $ts}] | .[-20:]' \
"$CONTEXT_FILE" > "${CONTEXT_FILE}.tmp" && mv "${CONTEXT_FILE}.tmp" "$CONTEXT_FILE"
echo "[Context] Saved: $FACT" >&2
fi
exit 0

27
plugin/scripts/check-debug.sh Executable file
View file

@ -0,0 +1,27 @@
#!/bin/bash
# Warn about debug statements left in code after edits
read -r input
FILE_PATH=$(echo "$input" | jq -r '.tool_input.file_path // empty')
if [[ -n "$FILE_PATH" && -f "$FILE_PATH" ]]; then
case "$FILE_PATH" in
*.go)
# Check for fmt.Println, log.Println debug statements
if grep -n "fmt\.Println\|log\.Println" "$FILE_PATH" 2>/dev/null | head -3 | grep -q .; then
echo "[Hook] WARNING: Debug prints found in $FILE_PATH" >&2
grep -n "fmt\.Println\|log\.Println" "$FILE_PATH" 2>/dev/null | head -3 >&2
fi
;;
*.php)
# Check for dd(), dump(), var_dump(), print_r()
if grep -n "dd(\|dump(\|var_dump(\|print_r(" "$FILE_PATH" 2>/dev/null | head -3 | grep -q .; then
echo "[Hook] WARNING: Debug statements found in $FILE_PATH" >&2
grep -n "dd(\|dump(\|var_dump(\|print_r(" "$FILE_PATH" 2>/dev/null | head -3 >&2
fi
;;
esac
fi
# Pass through the input
echo "$input"

View file

@ -0,0 +1,34 @@
#!/bin/bash
# Extract actionable items from core CLI output
# Called PostToolUse on Bash commands that run core
read -r input
COMMAND=$(echo "$input" | jq -r '.tool_input.command // empty')
OUTPUT=$(echo "$input" | jq -r '.tool_output.output // empty')
CONTEXT_SCRIPT="$(dirname "$0")/capture-context.sh"
# Extract actionables from specific core commands
case "$COMMAND" in
"core go qa"*|"core go test"*|"core go lint"*)
# Extract error/warning lines
echo "$OUTPUT" | grep -E "^(ERROR|WARN|FAIL|---)" | head -5 | while read -r line; do
"$CONTEXT_SCRIPT" "$line" "core go"
done
;;
"core php test"*|"core php analyse"*)
# Extract PHP errors
echo "$OUTPUT" | grep -E "^(FAIL|Error|×)" | head -5 | while read -r line; do
"$CONTEXT_SCRIPT" "$line" "core php"
done
;;
"core build"*)
# Extract build errors
echo "$OUTPUT" | grep -E "^(error|cannot|undefined)" | head -5 | while read -r line; do
"$CONTEXT_SCRIPT" "$line" "core build"
done
;;
esac
# Pass through
echo "$input"

19
plugin/scripts/go-format.sh Executable file
View file

@ -0,0 +1,19 @@
#!/bin/bash
# Auto-format Go files after edits using core go fmt
read -r input
FILE_PATH=$(echo "$input" | jq -r '.tool_input.file_path // empty')
if [[ -n "$FILE_PATH" && -f "$FILE_PATH" ]]; then
# Run gofmt/goimports on the file silently
if command -v core &> /dev/null; then
core go fmt --fix "$FILE_PATH" 2>/dev/null || true
elif command -v goimports &> /dev/null; then
goimports -w "$FILE_PATH" 2>/dev/null || true
elif command -v gofmt &> /dev/null; then
gofmt -w "$FILE_PATH" 2>/dev/null || true
fi
fi
# Pass through the input
echo "$input"

17
plugin/scripts/php-format.sh Executable file
View file

@ -0,0 +1,17 @@
#!/bin/bash
# Auto-format PHP files after edits using core php fmt
read -r input
FILE_PATH=$(echo "$input" | jq -r '.tool_input.file_path // empty')
if [[ -n "$FILE_PATH" && -f "$FILE_PATH" ]]; then
# Run Pint on the file silently
if command -v core &> /dev/null; then
core php fmt --fix "$FILE_PATH" 2>/dev/null || true
elif [[ -f "./vendor/bin/pint" ]]; then
./vendor/bin/pint "$FILE_PATH" 2>/dev/null || true
fi
fi
# Pass through the input
echo "$input"

View file

@ -0,0 +1,51 @@
#!/bin/bash
# Post-commit hook: Check for uncommitted work that might get lost
#
# After committing task-specific files, check if there's other work
# in the repo that should be committed or stashed
read -r input
COMMAND=$(echo "$input" | jq -r '.tool_input.command // empty')
# Only run after git commit
if ! echo "$COMMAND" | grep -qE '^git commit'; then
echo "$input"
exit 0
fi
# Check for remaining uncommitted changes
UNSTAGED=$(git diff --name-only 2>/dev/null | wc -l | tr -d ' ')
STAGED=$(git diff --cached --name-only 2>/dev/null | wc -l | tr -d ' ')
UNTRACKED=$(git ls-files --others --exclude-standard 2>/dev/null | wc -l | tr -d ' ')
TOTAL=$((UNSTAGED + STAGED + UNTRACKED))
if [[ $TOTAL -gt 0 ]]; then
echo "" >&2
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" >&2
echo "[PostCommit] WARNING: Uncommitted work remains" >&2
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" >&2
if [[ $UNSTAGED -gt 0 ]]; then
echo " Modified (unstaged): $UNSTAGED files" >&2
git diff --name-only 2>/dev/null | head -5 | sed 's/^/ /' >&2
[[ $UNSTAGED -gt 5 ]] && echo " ... and $((UNSTAGED - 5)) more" >&2
fi
if [[ $STAGED -gt 0 ]]; then
echo " Staged (not committed): $STAGED files" >&2
git diff --cached --name-only 2>/dev/null | head -5 | sed 's/^/ /' >&2
fi
if [[ $UNTRACKED -gt 0 ]]; then
echo " Untracked: $UNTRACKED files" >&2
git ls-files --others --exclude-standard 2>/dev/null | head -5 | sed 's/^/ /' >&2
[[ $UNTRACKED -gt 5 ]] && echo " ... and $((UNTRACKED - 5)) more" >&2
fi
echo "" >&2
echo "Consider: commit these, stash them, or confirm they're intentionally left" >&2
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" >&2
fi
echo "$input"

18
plugin/scripts/pr-created.sh Executable file
View file

@ -0,0 +1,18 @@
#!/bin/bash
# Log PR URL and provide review command after PR creation
read -r input
COMMAND=$(echo "$input" | jq -r '.tool_input.command // empty')
OUTPUT=$(echo "$input" | jq -r '.tool_output.output // empty')
if [[ "$COMMAND" == *"gh pr create"* ]]; then
PR_URL=$(echo "$OUTPUT" | grep -oE 'https://github.com/[^/]+/[^/]+/pull/[0-9]+' | head -1)
if [[ -n "$PR_URL" ]]; then
REPO=$(echo "$PR_URL" | sed -E 's|https://github.com/([^/]+/[^/]+)/pull/[0-9]+|\1|')
PR_NUM=$(echo "$PR_URL" | sed -E 's|.*/pull/([0-9]+)|\1|')
echo "[Hook] PR created: $PR_URL" >&2
echo "[Hook] To review: gh pr review $PR_NUM --repo $REPO" >&2
fi
fi
echo "$input"

69
plugin/scripts/pre-compact.sh Executable file
View file

@ -0,0 +1,69 @@
#!/bin/bash
# Pre-compact: Save minimal state for Claude to resume after auto-compact
#
# Captures:
# - Working directory + branch
# - Git status (files touched)
# - Todo state (in_progress items)
# - Context facts (decisions, actionables)
STATE_FILE="${HOME}/.claude/sessions/scratchpad.md"
CONTEXT_FILE="${HOME}/.claude/sessions/context.json"
TIMESTAMP=$(date '+%s')
CWD=$(pwd)
mkdir -p "${HOME}/.claude/sessions"
# Get todo state
TODOS=""
if [[ -f "${HOME}/.claude/todos/current.json" ]]; then
TODOS=$(cat "${HOME}/.claude/todos/current.json" 2>/dev/null | head -50)
fi
# Get git status
GIT_STATUS=""
BRANCH=""
if git rev-parse --git-dir > /dev/null 2>&1; then
GIT_STATUS=$(git status --short 2>/dev/null | head -15)
BRANCH=$(git branch --show-current 2>/dev/null)
fi
# Get context facts
CONTEXT=""
if [[ -f "$CONTEXT_FILE" ]]; then
CONTEXT=$(jq -r '.[] | "- [\(.source)] \(.fact)"' "$CONTEXT_FILE" 2>/dev/null | tail -10)
fi
cat > "$STATE_FILE" << EOF
---
timestamp: ${TIMESTAMP}
cwd: ${CWD}
branch: ${BRANCH:-none}
---
# Resume After Compact
You were mid-task. Do NOT assume work is complete.
## Project
\`${CWD}\` on \`${BRANCH:-no branch}\`
## Files Changed
\`\`\`
${GIT_STATUS:-none}
\`\`\`
## Todos (in_progress = NOT done)
\`\`\`json
${TODOS:-check /todos}
\`\`\`
## Context (decisions & actionables)
${CONTEXT:-none captured}
## Next
Continue the in_progress todo.
EOF
echo "[PreCompact] Snapshot saved" >&2
exit 0

34
plugin/scripts/session-start.sh Executable file
View file

@ -0,0 +1,34 @@
#!/bin/bash
# Session start: Read scratchpad if recent, otherwise start fresh
# 3 hour window - if older, you've moved on mentally
STATE_FILE="${HOME}/.claude/sessions/scratchpad.md"
THREE_HOURS=10800 # seconds
if [[ -f "$STATE_FILE" ]]; then
# Get timestamp from file
FILE_TS=$(grep -E '^timestamp:' "$STATE_FILE" 2>/dev/null | cut -d' ' -f2)
NOW=$(date '+%s')
if [[ -n "$FILE_TS" ]]; then
AGE=$((NOW - FILE_TS))
if [[ $AGE -lt $THREE_HOURS ]]; then
# Recent - read it back
echo "[SessionStart] Found recent scratchpad ($(($AGE / 60)) min ago)" >&2
echo "[SessionStart] Reading previous state..." >&2
echo "" >&2
cat "$STATE_FILE" >&2
echo "" >&2
else
# Stale - delete and start fresh
rm -f "$STATE_FILE"
echo "[SessionStart] Previous session >3h old - starting fresh" >&2
fi
else
# No timestamp, delete it
rm -f "$STATE_FILE"
fi
fi
exit 0

View file

@ -0,0 +1,28 @@
#!/bin/bash
# Suggest /compact at logical intervals to manage context window
# Tracks tool calls per session, suggests compaction every 50 calls
SESSION_ID="${CLAUDE_SESSION_ID:-$$}"
COUNTER_FILE="/tmp/claude-tool-count-${SESSION_ID}"
THRESHOLD="${COMPACT_THRESHOLD:-50}"
# Read or initialize counter
if [[ -f "$COUNTER_FILE" ]]; then
COUNT=$(($(cat "$COUNTER_FILE") + 1))
else
COUNT=1
fi
echo "$COUNT" > "$COUNTER_FILE"
# Suggest compact at threshold
if [[ $COUNT -eq $THRESHOLD ]]; then
echo "[Compact] ${THRESHOLD} tool calls - consider /compact if transitioning phases" >&2
fi
# Suggest at intervals after threshold
if [[ $COUNT -gt $THRESHOLD ]] && [[ $((COUNT % 25)) -eq 0 ]]; then
echo "[Compact] ${COUNT} tool calls - good checkpoint for /compact" >&2
fi
exit 0

60
plugin/skills/core.md Normal file
View file

@ -0,0 +1,60 @@
---
name: core
description: Use when working in host-uk repositories, running tests, building, releasing, or managing multi-repo workflows. Provides the core CLI command reference.
---
# Core CLI
The `core` command provides a unified interface for Go/PHP development and multi-repo management.
**Rule:** Always prefer `core <command>` over raw commands.
## Quick Reference
| Task | Command |
|------|---------|
| Go tests | `core go test` |
| Go coverage | `core go cov` |
| Go format | `core go fmt --fix` |
| Go lint | `core go lint` |
| PHP dev server | `core php dev` |
| PHP tests | `core php test` |
| PHP format | `core php fmt --fix` |
| Build | `core build` |
| Preview release | `core ci` |
| Publish | `core ci --were-go-for-launch` |
| Multi-repo status | `core dev health` |
| Commit dirty repos | `core dev commit` |
| Push repos | `core dev push` |
## Decision Tree
```
Go project?
tests: core go test
format: core go fmt --fix
build: core build
PHP project?
dev: core php dev
tests: core php test
format: core php fmt --fix
deploy: core php deploy
Multiple repos?
status: core dev health
commit: core dev commit
push: core dev push
```
## Common Mistakes
| Wrong | Right |
|-------|-------|
| `go test ./...` | `core go test` |
| `go build` | `core build` |
| `php artisan serve` | `core php dev` |
| `./vendor/bin/pest` | `core php test` |
| `git status` per repo | `core dev health` |
Run `core --help` or `core <cmd> --help` for full options.

107
plugin/skills/go.md Normal file
View file

@ -0,0 +1,107 @@
---
name: core-go
description: Use when creating Go packages or extending the core CLI.
---
# Go Framework Patterns
Core CLI uses `pkg/` for reusable packages. Use `core go` commands.
## Package Structure
```
core/
├── main.go # CLI entry point
├── pkg/
│ ├── cli/ # CLI framework, output, errors
│ ├── {domain}/ # Domain package
│ │ ├── cmd_{name}.go # Cobra command definitions
│ │ ├── service.go # Business logic
│ │ └── *_test.go # Tests
│ └── ...
└── internal/ # Private packages
```
## Adding a CLI Command
1. Create `pkg/{domain}/cmd_{name}.go`:
```go
package domain
import (
"github.com/host-uk/core/pkg/cli"
"github.com/spf13/cobra"
)
func NewNameCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "name",
Short: cli.T("domain.name.short"),
RunE: func(cmd *cobra.Command, args []string) error {
// Implementation
cli.Success("Done")
return nil
},
}
return cmd
}
```
2. Register in parent command.
## CLI Output Helpers
```go
import "github.com/host-uk/core/pkg/cli"
cli.Success("Operation completed") // Green check
cli.Warning("Something to note") // Yellow warning
cli.Error("Something failed") // Red error
cli.Info("Informational message") // Blue info
cli.Fatal(err) // Print error and exit 1
// Structured output
cli.Table(headers, rows)
cli.JSON(data)
```
## i18n Pattern
```go
// Use cli.T() for translatable strings
cli.T("domain.action.success")
cli.T("domain.action.error", "details", value)
// Define in pkg/i18n/locales/en.yaml:
domain:
action:
success: "Operation completed successfully"
error: "Failed: {{.details}}"
```
## Test Naming
```go
func TestFeature_Good(t *testing.T) { /* happy path */ }
func TestFeature_Bad(t *testing.T) { /* expected errors */ }
func TestFeature_Ugly(t *testing.T) { /* panics, edge cases */ }
```
## Commands
| Task | Command |
|------|---------|
| Run tests | `core go test` |
| Coverage | `core go cov` |
| Format | `core go fmt --fix` |
| Lint | `core go lint` |
| Build | `core build` |
| Install | `core go install` |
## Rules
- `CGO_ENABLED=0` for all builds
- UK English in user-facing strings
- All errors via `cli.E("context", "message", err)`
- Table-driven tests preferred

120
plugin/skills/php.md Normal file
View file

@ -0,0 +1,120 @@
---
name: core-php
description: Use when creating PHP modules, services, or actions in core-* packages.
---
# PHP Framework Patterns
Host UK PHP modules follow strict conventions. Use `core php` commands.
## Module Structure
```
core-{name}/
├── src/
│ ├── Core/ # Namespace: Core\{Name}
│ │ ├── Boot.php # Module bootstrap (listens to lifecycle events)
│ │ ├── Actions/ # Single-purpose business logic
│ │ └── Models/ # Eloquent models
│ └── Mod/ # Namespace: Core\Mod\{Name} (optional extensions)
├── resources/views/ # Blade templates
├── routes/ # Route definitions
├── database/migrations/ # Migrations
├── tests/ # Pest tests
└── composer.json
```
## Boot Class Pattern
```php
<?php
declare(strict_types=1);
namespace Core\{Name};
use Core\Php\Events\WebRoutesRegistering;
use Core\Php\Events\AdminPanelBooting;
class Boot
{
public static array $listens = [
WebRoutesRegistering::class => 'onWebRoutes',
AdminPanelBooting::class => ['onAdmin', 10], // With priority
];
public function onWebRoutes(WebRoutesRegistering $event): void
{
$event->router->middleware('web')->group(__DIR__ . '/../routes/web.php');
}
public function onAdmin(AdminPanelBooting $event): void
{
$event->panel->resources([...]);
}
}
```
## Action Pattern
```php
<?php
declare(strict_types=1);
namespace Core\{Name}\Actions;
use Core\Php\Action;
class CreateThing
{
use Action;
public function handle(User $user, array $data): Thing
{
return Thing::create([
'user_id' => $user->id,
...$data,
]);
}
}
// Usage: CreateThing::run($user, $validated);
```
## Multi-Tenant Models
```php
<?php
declare(strict_types=1);
namespace Core\{Name}\Models;
use Core\Tenant\Concerns\BelongsToWorkspace;
use Illuminate\Database\Eloquent\Model;
class Thing extends Model
{
use BelongsToWorkspace; // Auto-scopes queries, sets workspace_id
protected $fillable = ['name', 'workspace_id'];
}
```
## Commands
| Task | Command |
|------|---------|
| Run tests | `core php test` |
| Format | `core php fmt --fix` |
| Analyse | `core php analyse` |
| Dev server | `core php dev` |
## Rules
- Always `declare(strict_types=1);`
- UK English: colour, organisation, centre
- Type hints on all parameters and returns
- Pest for tests, not PHPUnit
- Flux Pro for UI, not vanilla Alpine