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:
parent
96eaed507c
commit
a93cc3540a
23 changed files with 3294 additions and 0 deletions
134
docs/plans/2026-01-30-i18n-v2-design.md
Normal file
134
docs/plans/2026-01-30-i18n-v2-design.md
Normal 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
|
||||
1685
docs/plans/2026-01-31-semantic-cli-output.md
Normal file
1685
docs/plans/2026-01-31-semantic-cli-output.md
Normal file
File diff suppressed because it is too large
Load diff
193
pkg/cli/command.go
Normal file
193
pkg/cli/command.go
Normal 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
116
pkg/cli/errors.go
Normal 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
176
pkg/cli/output.go
Normal 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
88
pkg/cli/strings.go
Normal 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)
|
||||
}
|
||||
36
plugin/commands/remember.md
Normal file
36
plugin/commands/remember.md
Normal 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
102
plugin/hooks/prefer-core.sh
Executable 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
109
plugin/plugin.json
Normal 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
27
plugin/scripts/block-docs.sh
Executable 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"
|
||||
44
plugin/scripts/capture-context.sh
Executable file
44
plugin/scripts/capture-context.sh
Executable 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
27
plugin/scripts/check-debug.sh
Executable 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"
|
||||
34
plugin/scripts/extract-actionables.sh
Executable file
34
plugin/scripts/extract-actionables.sh
Executable 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
19
plugin/scripts/go-format.sh
Executable 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
17
plugin/scripts/php-format.sh
Executable 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"
|
||||
51
plugin/scripts/post-commit-check.sh
Executable file
51
plugin/scripts/post-commit-check.sh
Executable 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
18
plugin/scripts/pr-created.sh
Executable 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
69
plugin/scripts/pre-compact.sh
Executable 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
34
plugin/scripts/session-start.sh
Executable 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
|
||||
28
plugin/scripts/suggest-compact.sh
Executable file
28
plugin/scripts/suggest-compact.sh
Executable 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
60
plugin/skills/core.md
Normal 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
107
plugin/skills/go.md
Normal 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
120
plugin/skills/php.md
Normal 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
|
||||
Loading…
Add table
Reference in a new issue