Initial commit
This commit is contained in:
19
.claude-plugin/plugin.json
Normal file
19
.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "workflow",
|
||||||
|
"description": "Development workflow enhancements including OpenSpec proposal management, skill auto-activation, and command hooks for session checklists, type checking, commit guards, and branch name validation.",
|
||||||
|
"version": "0.5.0",
|
||||||
|
"author": {
|
||||||
|
"name": "Jace Babin",
|
||||||
|
"email": "jbabin91@gmail.com",
|
||||||
|
"url": "https://github.com/jbabin91"
|
||||||
|
},
|
||||||
|
"skills": [
|
||||||
|
"./skills"
|
||||||
|
],
|
||||||
|
"commands": [
|
||||||
|
"./commands"
|
||||||
|
],
|
||||||
|
"hooks": [
|
||||||
|
"./hooks"
|
||||||
|
]
|
||||||
|
}
|
||||||
3
README.md
Normal file
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# workflow
|
||||||
|
|
||||||
|
Development workflow enhancements including OpenSpec proposal management, skill auto-activation, and command hooks for session checklists, type checking, commit guards, and branch name validation.
|
||||||
510
commands/configure.md
Normal file
510
commands/configure.md
Normal file
@@ -0,0 +1,510 @@
|
|||||||
|
---
|
||||||
|
name: configure
|
||||||
|
description: Generate or update project-level plugin configuration with smart migration
|
||||||
|
version: 2.0.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# Configure Plugin Settings
|
||||||
|
|
||||||
|
This command helps you set up `.claude/super-claude-config.json` for customizing plugin behavior (skills and hooks).
|
||||||
|
|
||||||
|
## What This Does
|
||||||
|
|
||||||
|
Creates or updates `.claude/super-claude-config.json` with configuration for:
|
||||||
|
|
||||||
|
- **Skills** - Auto-activation triggers, enable/disable
|
||||||
|
- **Hooks** - Behavior customization (protected branches, timeouts, etc.)
|
||||||
|
- **All installed plugins** - Unified configuration in one file
|
||||||
|
|
||||||
|
## Your Task
|
||||||
|
|
||||||
|
Execute this workflow intelligently based on what configuration files exist:
|
||||||
|
|
||||||
|
### Step 1: Detect Existing Configuration
|
||||||
|
|
||||||
|
Check for these files:
|
||||||
|
|
||||||
|
- **New format**: `.claude/super-claude-config.json`
|
||||||
|
- **Legacy format**: `.claude/skills/skill-rules.json`
|
||||||
|
|
||||||
|
### Step 2: Determine Strategy (Smart Routing)
|
||||||
|
|
||||||
|
**CASE 1: No configuration files exist (Clean Slate)**
|
||||||
|
|
||||||
|
- Generate `.claude/super-claude-config.json` from all plugin defaults
|
||||||
|
- No questions needed, just create it
|
||||||
|
- Skip to Step 4 (Generation)
|
||||||
|
|
||||||
|
**CASE 2: Only legacy config exists**
|
||||||
|
|
||||||
|
Use AskUserQuestion:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"question": "Found legacy skill-rules.json configuration. How should we handle it?",
|
||||||
|
"header": "Migration",
|
||||||
|
"multiSelect": false,
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"label": "Migrate to new format",
|
||||||
|
"description": "Convert to super-claude-config.json and create backup (.bak file)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Keep legacy format",
|
||||||
|
"description": "Create new config separately (both will work, legacy shows deprecation warning)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Actions based on answer:
|
||||||
|
|
||||||
|
- **"Migrate to new format"**: Follow Migration workflow (Step 3a)
|
||||||
|
- **"Keep legacy format"**: Generate new config only, don't touch legacy (Step 4)
|
||||||
|
|
||||||
|
**CASE 3: Only new config exists**
|
||||||
|
|
||||||
|
Check if new plugin defaults are available:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Pseudo-code logic
|
||||||
|
const current = loadJson('.claude/super-claude-config.json');
|
||||||
|
const allPluginDefaults = discoverPluginDefaults(); // from plugins/*/super-claude-config.json
|
||||||
|
const newAdditions = computeNewAdditions(current, allPluginDefaults);
|
||||||
|
|
||||||
|
if (newAdditions.isEmpty()) {
|
||||||
|
// No updates needed
|
||||||
|
console.log('✓ Configuration is up to date (all plugins configured)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If new defaults found, use AskUserQuestion:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"question": "Found ${count} new configuration options. Add them to your config?",
|
||||||
|
"header": "Update",
|
||||||
|
"multiSelect": false,
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"label": "Add new defaults",
|
||||||
|
"description": "Add ${summary} while preserving all your customizations"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Skip for now",
|
||||||
|
"description": "Don't update (you can run /workflow:configure again later)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Actions based on answer:
|
||||||
|
|
||||||
|
- **"Add new defaults"**: Merge new defaults (Step 3b)
|
||||||
|
- **"Skip for now"**: Exit without changes
|
||||||
|
|
||||||
|
**CASE 4: Both configs exist**
|
||||||
|
|
||||||
|
Use AskUserQuestion:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"question": "Both legacy and new configuration files exist. What should we do?",
|
||||||
|
"header": "Conflict",
|
||||||
|
"multiSelect": false,
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"label": "Merge into new config",
|
||||||
|
"description": "Import legacy overrides into super-claude-config.json (creates backup)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Update new config only",
|
||||||
|
"description": "Ignore legacy file and just update super-claude-config.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Keep both separate",
|
||||||
|
"description": "Don't merge (both will be loaded, legacy shows deprecation warning)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Actions based on answer:
|
||||||
|
|
||||||
|
- **"Merge into new config"**: Migration + merge workflow (Step 3a)
|
||||||
|
- **"Update new config only"**: Update new config, ignore legacy (Step 3b)
|
||||||
|
- **"Keep both separate"**: Exit without changes
|
||||||
|
|
||||||
|
### Step 3a: Migration Workflow
|
||||||
|
|
||||||
|
**Convert legacy skill-rules.json to new format:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function migrateLegacyConfig(legacyPath: string): ProjectConfig {
|
||||||
|
const legacy = JSON.parse(fs.readFileSync(legacyPath, 'utf8'));
|
||||||
|
const migrated: ProjectConfig = {};
|
||||||
|
|
||||||
|
// Legacy format has:
|
||||||
|
// - plugin.namespace (e.g., "meta")
|
||||||
|
// - skills.{skillName}
|
||||||
|
// - overrides.disabled (array of "namespace/skillName")
|
||||||
|
|
||||||
|
const namespace = legacy.plugin.namespace;
|
||||||
|
|
||||||
|
if (!migrated[namespace]) {
|
||||||
|
migrated[namespace] = { skills: {}, hooks: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert disabled skills
|
||||||
|
if (legacy.overrides?.disabled) {
|
||||||
|
for (const fullName of legacy.overrides.disabled) {
|
||||||
|
const [ns, skillName] = fullName.split('/');
|
||||||
|
if (ns === namespace) {
|
||||||
|
migrated[namespace].skills[skillName] = { enabled: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert skill overrides (if any custom triggers, priorities, etc.)
|
||||||
|
for (const [skillName, skillData] of Object.entries(legacy.skills || {})) {
|
||||||
|
if (!migrated[namespace].skills[skillName]) {
|
||||||
|
migrated[namespace].skills[skillName] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve custom triggers if they exist
|
||||||
|
if (skillData.promptTriggers) {
|
||||||
|
migrated[namespace].skills[skillName].triggers = {
|
||||||
|
keywords: skillData.promptTriggers.keywords || [],
|
||||||
|
patterns: skillData.promptTriggers.intentPatterns || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return migrated;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Workflow:**
|
||||||
|
|
||||||
|
1. Read `.claude/skills/skill-rules.json`
|
||||||
|
2. Convert to new format using migration logic above
|
||||||
|
3. Create backup: `cp .claude/skills/skill-rules.json .claude/skills/skill-rules.json.bak`
|
||||||
|
4. Load all plugin defaults
|
||||||
|
5. Deep merge: `plugin defaults → migrated overrides → existing new config (if any)`
|
||||||
|
6. Write to `.claude/super-claude-config.json`
|
||||||
|
|
||||||
|
### Step 3b: Update Existing Config
|
||||||
|
|
||||||
|
**Add only NEW plugins/hooks while preserving ALL user customizations:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function addNewDefaults(
|
||||||
|
current: ProjectConfig,
|
||||||
|
allDefaults: Record<string, PluginConfig>,
|
||||||
|
): ProjectConfig {
|
||||||
|
const result = { ...current };
|
||||||
|
|
||||||
|
for (const [pluginName, pluginDefaults] of Object.entries(allDefaults)) {
|
||||||
|
if (!result[pluginName]) {
|
||||||
|
// Brand new plugin - add entire config
|
||||||
|
result[pluginName] = {
|
||||||
|
skills: pluginDefaults.skills || {},
|
||||||
|
hooks: pluginDefaults.hooks || {},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Existing plugin - add only NEW skills/hooks
|
||||||
|
|
||||||
|
// Add new skills
|
||||||
|
if (pluginDefaults.skills) {
|
||||||
|
result[pluginName].skills = result[pluginName].skills || {};
|
||||||
|
for (const [skillName, skillConfig] of Object.entries(
|
||||||
|
pluginDefaults.skills,
|
||||||
|
)) {
|
||||||
|
if (!result[pluginName].skills[skillName]) {
|
||||||
|
// Only add if doesn't exist (preserve user customizations)
|
||||||
|
result[pluginName].skills[skillName] = skillConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new hooks
|
||||||
|
if (pluginDefaults.hooks) {
|
||||||
|
result[pluginName].hooks = result[pluginName].hooks || {};
|
||||||
|
for (const [hookName, hookConfig] of Object.entries(
|
||||||
|
pluginDefaults.hooks,
|
||||||
|
)) {
|
||||||
|
if (!result[pluginName].hooks[hookName]) {
|
||||||
|
// Only add if doesn't exist (preserve user customizations)
|
||||||
|
result[pluginName].hooks[hookName] = hookConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Generate Configuration
|
||||||
|
|
||||||
|
**Discover all plugin defaults:**
|
||||||
|
|
||||||
|
1. Find all `plugins/*/super-claude-config.json` files in the repository
|
||||||
|
2. Extract `skills` and `hooks` from each plugin's config
|
||||||
|
3. Build a map of plugin-name → { skills, hooks }
|
||||||
|
|
||||||
|
**Generate the configuration file:**
|
||||||
|
|
||||||
|
Use this EXACT template structure - do NOT modify the `$schema` path or repo URL:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "../../.claude-plugin/super-claude-config.schema.json",
|
||||||
|
"_comment": [
|
||||||
|
"Super Claude Plugin Configuration",
|
||||||
|
"",
|
||||||
|
"This file controls plugin behavior for this project.",
|
||||||
|
"Configuration priority: this file > plugin defaults > environment variables",
|
||||||
|
"",
|
||||||
|
"Common customizations:",
|
||||||
|
" • Disable a skill: Set 'enabled': false",
|
||||||
|
" • Change protected branches: Modify workflow.hooks.gitCommitGuard.protectedBranches",
|
||||||
|
" • Add branch prefixes: Modify workflow.hooks.branchNameValidator.allowedPrefixes",
|
||||||
|
" • Customize triggers: Add/modify 'keywords' or 'patterns' arrays",
|
||||||
|
"",
|
||||||
|
"Changes take effect immediately (no restart needed).",
|
||||||
|
"Documentation: https://github.com/jbabin91/super-claude"
|
||||||
|
],
|
||||||
|
"workflow": {
|
||||||
|
"skills": {},
|
||||||
|
"hooks": {}
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"skills": {},
|
||||||
|
"hooks": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**CRITICAL: Merge discovered defaults into this template structure:**
|
||||||
|
|
||||||
|
1. For each plugin discovered, add a top-level key with that plugin's name
|
||||||
|
2. Populate `skills` and `hooks` from the discovered defaults
|
||||||
|
3. Keep the `$schema` and `_comment` exactly as shown above
|
||||||
|
4. Format with Prettier using these rules:
|
||||||
|
- **NO blank lines between object properties**
|
||||||
|
- 2-space indentation
|
||||||
|
- Double quotes for strings
|
||||||
|
- Trailing commas where allowed
|
||||||
|
|
||||||
|
**Example after merging (if testing plugin discovered):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "../../.claude-plugin/super-claude-config.schema.json",
|
||||||
|
"_comment": [
|
||||||
|
"Super Claude Plugin Configuration",
|
||||||
|
"",
|
||||||
|
"This file controls plugin behavior for this project.",
|
||||||
|
"Configuration priority: this file > plugin defaults > environment variables",
|
||||||
|
"",
|
||||||
|
"Common customizations:",
|
||||||
|
" • Disable a skill: Set 'enabled': false",
|
||||||
|
" • Change protected branches: Modify workflow.hooks.gitCommitGuard.protectedBranches",
|
||||||
|
" • Add branch prefixes: Modify workflow.hooks.branchNameValidator.allowedPrefixes",
|
||||||
|
" • Customize triggers: Add/modify 'keywords' or 'patterns' arrays",
|
||||||
|
"",
|
||||||
|
"Changes take effect immediately (no restart needed).",
|
||||||
|
"Documentation: https://github.com/jbabin91/super-claude"
|
||||||
|
],
|
||||||
|
"workflow": {
|
||||||
|
"skills": {},
|
||||||
|
"hooks": {
|
||||||
|
"gitCommitGuard": {
|
||||||
|
"enabled": false,
|
||||||
|
"protectedBranches": ["main", "master"],
|
||||||
|
"bypassEnvVar": "SKIP_COMMIT_GUARD"
|
||||||
|
},
|
||||||
|
"branchNameValidator": {
|
||||||
|
"enabled": false,
|
||||||
|
"allowedPrefixes": [
|
||||||
|
"feat",
|
||||||
|
"fix",
|
||||||
|
"chore",
|
||||||
|
"docs",
|
||||||
|
"test",
|
||||||
|
"refactor",
|
||||||
|
"perf",
|
||||||
|
"build",
|
||||||
|
"ci",
|
||||||
|
"revert",
|
||||||
|
"style"
|
||||||
|
],
|
||||||
|
"allowedBranches": ["main", "master", "develop"]
|
||||||
|
},
|
||||||
|
"typeChecker": {
|
||||||
|
"enabled": true,
|
||||||
|
"timeout": 2000
|
||||||
|
},
|
||||||
|
"sessionChecklist": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"skills": {
|
||||||
|
"skill-creator": {
|
||||||
|
"enabled": true,
|
||||||
|
"triggers": {
|
||||||
|
"keywords": ["create skill", "new skill", "skill development"],
|
||||||
|
"patterns": ["(create|add|generate|build).*?skill"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"hook-creator": {
|
||||||
|
"enabled": true,
|
||||||
|
"triggers": {
|
||||||
|
"keywords": ["create hook", "new hook", "hook development"],
|
||||||
|
"patterns": ["(create|add|generate|build).*?hook"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"hooks": {}
|
||||||
|
},
|
||||||
|
"testing": {
|
||||||
|
"skills": {
|
||||||
|
"test-runner": {
|
||||||
|
"enabled": true,
|
||||||
|
"triggers": {
|
||||||
|
"keywords": ["run tests", "test"],
|
||||||
|
"patterns": ["(run|execute).*?tests?"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"hooks": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: Show Summary
|
||||||
|
|
||||||
|
Provide clear feedback based on what was done:
|
||||||
|
|
||||||
|
**For clean slate generation:**
|
||||||
|
|
||||||
|
```txt
|
||||||
|
✓ Created .claude/super-claude-config.json
|
||||||
|
|
||||||
|
Configured ${pluginCount} plugins:
|
||||||
|
• workflow (${skillCount} skills, ${hookCount} hooks)
|
||||||
|
• meta (${skillCount} skills, ${hookCount} hooks)
|
||||||
|
...
|
||||||
|
|
||||||
|
Next steps:
|
||||||
|
1. Open .claude/super-claude-config.json
|
||||||
|
2. Customize settings for your project
|
||||||
|
3. Changes take effect immediately (no restart needed)
|
||||||
|
|
||||||
|
Common customizations:
|
||||||
|
• Disable skill: Set "enabled": false
|
||||||
|
• Change protected branches: Modify workflow.hooks.gitCommitGuard.protectedBranches
|
||||||
|
• Add branch prefixes: Modify workflow.hooks.branchNameValidator.allowedPrefixes
|
||||||
|
|
||||||
|
Documentation: See docs/guides/plugin-configuration.md
|
||||||
|
```
|
||||||
|
|
||||||
|
**For migration:**
|
||||||
|
|
||||||
|
```txt
|
||||||
|
✓ Migrated legacy configuration
|
||||||
|
|
||||||
|
Actions taken:
|
||||||
|
• Created backup: .claude/skills/skill-rules.json.bak
|
||||||
|
• Migrated overrides → .claude/super-claude-config.json
|
||||||
|
• Merged with plugin defaults
|
||||||
|
|
||||||
|
Your customizations were preserved:
|
||||||
|
- meta/skill-creator: disabled
|
||||||
|
- Custom triggers for hook-creator
|
||||||
|
|
||||||
|
You can safely delete .claude/skills/skill-rules.json after verification.
|
||||||
|
The new config will be loaded automatically.
|
||||||
|
```
|
||||||
|
|
||||||
|
**For update:**
|
||||||
|
|
||||||
|
```txt
|
||||||
|
✓ Updated .claude/super-claude-config.json
|
||||||
|
|
||||||
|
Added new defaults:
|
||||||
|
+ Plugin: testing (3 skills, 2 hooks)
|
||||||
|
+ Hook: workflow/sessionChecklist
|
||||||
|
+ Hook: workflow/typeChecker
|
||||||
|
|
||||||
|
Your existing customizations were preserved:
|
||||||
|
- workflow/gitCommitGuard.protectedBranches: ["main", "production"]
|
||||||
|
- meta/skill-creator.enabled: false
|
||||||
|
```
|
||||||
|
|
||||||
|
**For up-to-date:**
|
||||||
|
|
||||||
|
```txt
|
||||||
|
✓ Configuration is up to date
|
||||||
|
|
||||||
|
All ${pluginCount} installed plugins are configured.
|
||||||
|
No new defaults available.
|
||||||
|
|
||||||
|
To reset to plugin defaults: Delete .claude/super-claude-config.json and run this command again.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
1. **Cannot create .claude/ directory**
|
||||||
|
- Check permissions
|
||||||
|
- Suggest: `chmod +w .claude`
|
||||||
|
|
||||||
|
2. **Plugin defaults not found**
|
||||||
|
- List plugins with missing configs
|
||||||
|
- Suggest: Check plugin installation
|
||||||
|
|
||||||
|
3. **Invalid JSON in existing config**
|
||||||
|
- Show parse error
|
||||||
|
- Offer to backup and regenerate
|
||||||
|
|
||||||
|
4. **File write fails**
|
||||||
|
- Show specific error
|
||||||
|
- Check disk space and permissions
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
- **Always preserve user customizations** - Never overwrite unless user explicitly confirms
|
||||||
|
- **Create backups** - Always create `.bak` files before modifying
|
||||||
|
- **Deep merge strategy** - User values always win over defaults
|
||||||
|
- **Idempotent** - Safe to run multiple times
|
||||||
|
- **No restart needed** - Config loader reads on every hook execution
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
After implementation, verify:
|
||||||
|
|
||||||
|
- [ ] Clean slate generation works
|
||||||
|
- [ ] Legacy migration creates backup
|
||||||
|
- [ ] Update adds only new defaults
|
||||||
|
- [ ] Conflict resolution offers all choices
|
||||||
|
- [ ] User customizations are never lost
|
||||||
|
- [ ] AskUserQuestion shows clear options
|
||||||
|
- [ ] Summary messages are accurate
|
||||||
|
- [ ] Generated JSON is valid and formatted
|
||||||
160
commands/generate-skill-rules.md
Normal file
160
commands/generate-skill-rules.md
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
---
|
||||||
|
name: generate-skill-rules
|
||||||
|
description: Generate skill-rules.json entries from SKILL.md YAML frontmatter (for maintainers)
|
||||||
|
version: 1.0.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# Generate Skill Rules
|
||||||
|
|
||||||
|
This command parses YAML frontmatter from SKILL.md files and generates `skill-rules.json` entries for the auto-activation system.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```sh
|
||||||
|
/generate-skill-rules [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
## What It Does
|
||||||
|
|
||||||
|
1. Scans for SKILL.md files in the specified directory
|
||||||
|
2. Parses YAML frontmatter to extract trigger information
|
||||||
|
3. Converts to skill-rules.json format
|
||||||
|
4. Outputs generated JSON to stdout or writes to file
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
- **--plugin** - Plugin directory to scan (default: current directory)
|
||||||
|
- **--namespace** - Plugin namespace (required if --write)
|
||||||
|
- **--write** - Write directly to skill-rules.json
|
||||||
|
- **--dry-run** - Output to stdout without writing (default)
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Preview Generated Rules
|
||||||
|
|
||||||
|
```sh
|
||||||
|
/generate-skill-rules --plugin plugins/tanstack-tools --namespace tanstack
|
||||||
|
```
|
||||||
|
|
||||||
|
### Write to skill-rules.json
|
||||||
|
|
||||||
|
```sh
|
||||||
|
/generate-skill-rules --plugin plugins/api-tools --namespace api --write
|
||||||
|
```
|
||||||
|
|
||||||
|
## Instructions for Claude
|
||||||
|
|
||||||
|
When this command is invoked:
|
||||||
|
|
||||||
|
1. **Locate SKILL.md Files**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
find {plugin-dir}/skills -name "SKILL.md" -type f
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Parse Each SKILL.md**
|
||||||
|
- Read YAML frontmatter (between `---` delimiters)
|
||||||
|
- Extract these fields:
|
||||||
|
- `name` → skill ID
|
||||||
|
- `description` → skill description
|
||||||
|
- `category` → determines skill type
|
||||||
|
- `triggers.keywords` → promptTriggers.keywords
|
||||||
|
- `triggers.patterns` → promptTriggers.intentPatterns
|
||||||
|
- `priority` (if present) → priority field
|
||||||
|
|
||||||
|
3. **Map Fields to skill-rules.json Schema**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"plugin": {
|
||||||
|
"name": "{extracted-from-plugin-json}",
|
||||||
|
"version": "{extracted-from-plugin-json}",
|
||||||
|
"namespace": "{from-cli-arg-or-plugin-json}"
|
||||||
|
},
|
||||||
|
"skills": {
|
||||||
|
"{skill-name}": {
|
||||||
|
"type": "domain",
|
||||||
|
"enforcement": "suggest",
|
||||||
|
"priority": "{from-yaml-or-default-high}",
|
||||||
|
"description": "{from-yaml}",
|
||||||
|
"promptTriggers": {
|
||||||
|
"keywords": ["{from-yaml-triggers-keywords}"],
|
||||||
|
"intentPatterns": ["{from-yaml-triggers-patterns}"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Handle Edge Cases**
|
||||||
|
- Skills without triggers → create empty arrays
|
||||||
|
- Skills without priority → default to "high"
|
||||||
|
- Skills with category "guardrail" → type: "guardrail"
|
||||||
|
- All other categories → type: "domain"
|
||||||
|
|
||||||
|
5. **Validation**
|
||||||
|
- Check namespace is valid (lowercase, no spaces)
|
||||||
|
- Ensure skill names are kebab-case
|
||||||
|
- Validate regex patterns are valid
|
||||||
|
- Warn about missing required fields
|
||||||
|
|
||||||
|
6. **Output Format**
|
||||||
|
- **Dry run**: Print formatted JSON to stdout with guidance
|
||||||
|
- **Write mode**: Create/update `{plugin-dir}/skills/skill-rules.json`
|
||||||
|
- Include success message with file location
|
||||||
|
|
||||||
|
## Example YAML → JSON Conversion
|
||||||
|
|
||||||
|
**SKILL.md frontmatter:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
name: skill-creator
|
||||||
|
description: Generate new Claude Code skills
|
||||||
|
category: workflow-automation
|
||||||
|
priority: high
|
||||||
|
triggers:
|
||||||
|
keywords:
|
||||||
|
- create skill
|
||||||
|
- new skill
|
||||||
|
- skill template
|
||||||
|
patterns:
|
||||||
|
- (create|add|generate).*?skill
|
||||||
|
- how to.*?create.*?skill
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
**Generated skill-rules.json entry:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"skill-creator": {
|
||||||
|
"type": "domain",
|
||||||
|
"enforcement": "suggest",
|
||||||
|
"priority": "high",
|
||||||
|
"description": "Generate new Claude Code skills",
|
||||||
|
"promptTriggers": {
|
||||||
|
"keywords": ["create skill", "new skill", "skill template"],
|
||||||
|
"intentPatterns": [
|
||||||
|
"(create|add|generate).*?skill",
|
||||||
|
"how to.*?create.*?skill"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- Successfully parses all SKILL.md files
|
||||||
|
- Generates valid JSON matching schema
|
||||||
|
- Handles skills without triggers gracefully
|
||||||
|
- Provides clear output and error messages
|
||||||
|
- Works for both dry-run and write modes
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- This is a maintainer tool for initial migration
|
||||||
|
- After migration, maintain skill-rules.json directly
|
||||||
|
- YAML triggers in SKILL.md can be removed after migration
|
||||||
|
- Always validate generated JSON with `/workflow:configure`
|
||||||
28
commands/openspec/apply.md
Normal file
28
commands/openspec/apply.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
name: openspec:apply
|
||||||
|
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||||
|
category: openspec
|
||||||
|
tags: [openspec, apply]
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- OPENSPEC:START -->
|
||||||
|
|
||||||
|
**Guardrails**
|
||||||
|
|
||||||
|
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
|
||||||
|
- Keep changes tightly scoped to the requested outcome.
|
||||||
|
- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications.
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
Track these steps as TODOs and complete them one by one.
|
||||||
|
|
||||||
|
1. Read `changes/<id>/proposal.md`, `design.md` (if present), and `tasks.md` to confirm scope and acceptance criteria.
|
||||||
|
2. Work through tasks sequentially, keeping edits minimal and focused on the requested change.
|
||||||
|
3. Confirm completion before updating statuses—make sure every item in `tasks.md` is finished.
|
||||||
|
4. Update the checklist after all work is done so each task is marked `- [x]` and reflects reality.
|
||||||
|
5. Reference `openspec list` or `openspec show <item>` when additional context is required.
|
||||||
|
|
||||||
|
**Reference**
|
||||||
|
|
||||||
|
- Use `openspec show <id> --json --deltas-only` if you need additional context from the proposal while implementing.
|
||||||
|
<!-- OPENSPEC:END -->
|
||||||
36
commands/openspec/archive.md
Normal file
36
commands/openspec/archive.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
name: openspec:archive
|
||||||
|
description: Archive a deployed OpenSpec change and update specs.
|
||||||
|
category: openspec
|
||||||
|
tags: [openspec, archive]
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- OPENSPEC:START -->
|
||||||
|
|
||||||
|
**Guardrails**
|
||||||
|
|
||||||
|
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
|
||||||
|
- Keep changes tightly scoped to the requested outcome.
|
||||||
|
- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications.
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
|
||||||
|
1. Determine the change ID to archive:
|
||||||
|
- If this prompt already includes a specific change ID (for example inside a `<ChangeId>` block populated by slash-command arguments), use that value after trimming whitespace.
|
||||||
|
- If the conversation references a change loosely (for example by title or summary), run `openspec list` to surface likely IDs, share the relevant candidates, and confirm which one the user intends.
|
||||||
|
- Otherwise, review the conversation, run `openspec list`, and ask the user which change to archive; wait for a confirmed change ID before proceeding.
|
||||||
|
- If you still cannot identify a single change ID, stop and tell the user you cannot archive anything yet.
|
||||||
|
2. Validate the change ID by running `openspec list` (or `openspec show <id>`) and stop if the change is missing, already archived, or otherwise not ready to archive.
|
||||||
|
3. Run `openspec archive <id> --yes` so the CLI moves the change and applies spec updates without prompts (use `--skip-specs` only for tooling-only work).
|
||||||
|
4. Review the command output to confirm the target specs were updated and the change landed in `changes/archive/`.
|
||||||
|
5. **Update CHANGELOG.md** - Add entry under appropriate version (Unreleased or next version) and category:
|
||||||
|
- **Added** - New skills/features
|
||||||
|
- **Changed** - Modified existing functionality
|
||||||
|
- **Fixed** - Bug fixes
|
||||||
|
6. Validate with `openspec validate --strict` and inspect with `openspec show <id>` if anything looks off.
|
||||||
|
|
||||||
|
**Reference**
|
||||||
|
|
||||||
|
- Use `openspec list` to confirm change IDs before archiving.
|
||||||
|
- Inspect refreshed specs with `openspec list --specs` and address any validation issues before handing off.
|
||||||
|
<!-- OPENSPEC:END -->
|
||||||
162
commands/openspec/checkpoint.md
Normal file
162
commands/openspec/checkpoint.md
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
---
|
||||||
|
name: openspec:checkpoint
|
||||||
|
description: Save current progress and context to design.md.
|
||||||
|
category: openspec
|
||||||
|
tags: [openspec, checkpoint, save]
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- OPENSPEC:START -->
|
||||||
|
|
||||||
|
**Purpose**
|
||||||
|
|
||||||
|
This command saves your current progress as a checkpoint in design.md. It's the equivalent of the Reddit post's "checkpoint" concept - preserving context so you can resume work later without losing your train of thought.
|
||||||
|
|
||||||
|
**When to Use**
|
||||||
|
|
||||||
|
- Before taking a break or ending a session
|
||||||
|
- After completing a significant subtask
|
||||||
|
- When you've made important decisions or discoveries
|
||||||
|
- Before switching to a different change
|
||||||
|
- Regularly during long work sessions (every 30-60 minutes)
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
|
||||||
|
1. **Check for active change**
|
||||||
|
- Read `openspec/active.json`
|
||||||
|
- If no active change: "No active change. Use /openspec:work to start working on a change first."
|
||||||
|
- Exit if no active change
|
||||||
|
|
||||||
|
2. **Gather current context**
|
||||||
|
- Ask user to summarize current progress:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Let's save a checkpoint. Please provide:
|
||||||
|
|
||||||
|
1. What did you just complete?
|
||||||
|
2. What are you working on now?
|
||||||
|
3. What's next?
|
||||||
|
4. Any blockers or decisions made?
|
||||||
|
|
||||||
|
(You can provide a brief summary or detailed notes)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Load existing design.md**
|
||||||
|
- Path: `openspec/changes/<change-id>/design.md`
|
||||||
|
- If doesn't exist, create with initial structure:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Design: <change-id>
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
(Brief description of the approach)
|
||||||
|
|
||||||
|
## Progress Log
|
||||||
|
|
||||||
|
### Checkpoint: <ISO 8601 timestamp>
|
||||||
|
|
||||||
|
(User's progress notes)
|
||||||
|
```
|
||||||
|
|
||||||
|
- If exists, append new checkpoint section
|
||||||
|
|
||||||
|
4. **Append checkpoint to design.md**
|
||||||
|
- Add new section at the end:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### Checkpoint: <ISO 8601 timestamp>
|
||||||
|
|
||||||
|
**Completed:**
|
||||||
|
|
||||||
|
- (what user completed)
|
||||||
|
|
||||||
|
**Current:**
|
||||||
|
|
||||||
|
- (what user is working on)
|
||||||
|
|
||||||
|
**Next:**
|
||||||
|
|
||||||
|
- (what's next)
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
|
||||||
|
- (any blockers/decisions)
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Update tasks.md if needed**
|
||||||
|
- Ask user: "Any tasks to mark complete in tasks.md? (y/n)"
|
||||||
|
- If yes, ask which task numbers to mark with `[x]`
|
||||||
|
- Update tasks.md with completed tasks
|
||||||
|
|
||||||
|
6. **Update active.json**
|
||||||
|
- Update `lastCheckpoint` field to current timestamp:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"change": "<change-id>",
|
||||||
|
"started": "<original timestamp>",
|
||||||
|
"lastCheckpoint": "<current ISO 8601 timestamp>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
7. **Confirm checkpoint saved**
|
||||||
|
|
||||||
|
```txt
|
||||||
|
✓ Checkpoint saved to design.md
|
||||||
|
|
||||||
|
Updated:
|
||||||
|
- design.md: Added progress notes
|
||||||
|
- tasks.md: Marked <X> tasks complete
|
||||||
|
- active.json: Updated lastCheckpoint
|
||||||
|
|
||||||
|
You can safely resume this work later. The SessionStart hook will load this context automatically.
|
||||||
|
|
||||||
|
Use /openspec:status to see current progress.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Handling**
|
||||||
|
|
||||||
|
- If no active change: Guide user to use /openspec:work first
|
||||||
|
- If design.md write fails: Show error and suggest checking file permissions
|
||||||
|
- If tasks.md parsing fails: Skip task updates but still save checkpoint
|
||||||
|
|
||||||
|
**Example Usage**
|
||||||
|
|
||||||
|
**Scenario 1: Quick checkpoint**
|
||||||
|
|
||||||
|
```txt
|
||||||
|
User: /openspec:checkpoint
|
||||||
|
Assistant: Let's save a checkpoint. Please provide:
|
||||||
|
1. What did you just complete?
|
||||||
|
2. What are you working on now?
|
||||||
|
3. What's next?
|
||||||
|
User: Just finished the theme provider. Working on the toggle component now. Next is localStorage persistence.
|
||||||
|
Assistant: [Appends to design.md]
|
||||||
|
[Updates active.json]
|
||||||
|
✓ Checkpoint saved
|
||||||
|
```
|
||||||
|
|
||||||
|
**Scenario 2: Checkpoint with task updates**
|
||||||
|
|
||||||
|
```txt
|
||||||
|
User: /openspec:checkpoint
|
||||||
|
Assistant: Let's save a checkpoint...
|
||||||
|
User: Completed theme context and provider setup
|
||||||
|
Assistant: Any tasks to mark complete in tasks.md? (y/n)
|
||||||
|
User: y
|
||||||
|
Assistant: Which task numbers? (e.g., 1,3,5)
|
||||||
|
User: 1,2
|
||||||
|
Assistant: [Updates tasks.md: marks tasks 1 and 2 complete]
|
||||||
|
[Appends checkpoint to design.md]
|
||||||
|
✓ Checkpoint saved. 2 tasks marked complete.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes**
|
||||||
|
|
||||||
|
- design.md acts as a "living doc" that grows with the project
|
||||||
|
- Checkpoints create a timeline of progress and decisions
|
||||||
|
- The SessionStart hook reads design.md to restore context
|
||||||
|
- This is inspired by diet103's "context.md" approach from Reddit
|
||||||
|
- Unlike git commits, checkpoints capture thought process and decisions
|
||||||
|
|
||||||
|
<!-- OPENSPEC:END -->
|
||||||
149
commands/openspec/done.md
Normal file
149
commands/openspec/done.md
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
---
|
||||||
|
name: openspec:done
|
||||||
|
description: Complete and archive an OpenSpec change.
|
||||||
|
category: openspec
|
||||||
|
tags: [openspec, done, archive, complete]
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- OPENSPEC:START -->
|
||||||
|
|
||||||
|
**Purpose**
|
||||||
|
|
||||||
|
This command finalizes your work on an OpenSpec change:
|
||||||
|
|
||||||
|
- Verifies all tasks are complete
|
||||||
|
- Archives the change proposal
|
||||||
|
- Updates CHANGELOG.md
|
||||||
|
- Clears the active change tracker
|
||||||
|
- Guides you to commit the changes
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
|
||||||
|
1. **Check for active change**
|
||||||
|
- Read `openspec/active.json`
|
||||||
|
- If no active change: "No active change. Use /openspec:work to start working on a change first."
|
||||||
|
- Exit if no active change
|
||||||
|
|
||||||
|
2. **Verify all tasks complete**
|
||||||
|
- Read `openspec/changes/<change-id>/tasks.md`
|
||||||
|
- Count incomplete tasks (lines with `- [ ]`)
|
||||||
|
- If any incomplete:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
⚠️ Not all tasks are complete!
|
||||||
|
|
||||||
|
Remaining tasks: <X>
|
||||||
|
- (list first 5 incomplete tasks)
|
||||||
|
|
||||||
|
Options:
|
||||||
|
1. Continue anyway and mark as done (not recommended)
|
||||||
|
2. Cancel and finish remaining tasks
|
||||||
|
3. Save checkpoint and resume later (/openspec:checkpoint)
|
||||||
|
|
||||||
|
What would you like to do?
|
||||||
|
```
|
||||||
|
|
||||||
|
- Wait for user decision
|
||||||
|
|
||||||
|
3. **Final checkpoint**
|
||||||
|
- Ask user: "Would you like to save a final checkpoint before archiving? (y/n)"
|
||||||
|
- If yes, run /openspec:checkpoint workflow
|
||||||
|
- This captures final state in design.md
|
||||||
|
|
||||||
|
4. **Run OpenSpec archive**
|
||||||
|
- Execute: `openspec archive <change-id>`
|
||||||
|
- This moves the change to archived folder with timestamp
|
||||||
|
- Updates specs/ folder with approved changes
|
||||||
|
- Shows success message from OpenSpec CLI
|
||||||
|
|
||||||
|
5. **Clear active change tracker**
|
||||||
|
- Delete or clear `openspec/active.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Or delete the file entirely
|
||||||
|
|
||||||
|
6. **Guide CHANGELOG update**
|
||||||
|
- Show message:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
✓ Change archived successfully!
|
||||||
|
|
||||||
|
Next steps:
|
||||||
|
1. Review the changes made by OpenSpec
|
||||||
|
2. Update CHANGELOG.md with this change:
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **<change-id>** - <brief description>
|
||||||
|
|
||||||
|
3. Commit all changes:
|
||||||
|
git add .
|
||||||
|
git commit -m "feat: <change description>"
|
||||||
|
|
||||||
|
4. The change is now archived in:
|
||||||
|
openspec/changes/<timestamp>-<change-id>/
|
||||||
|
|
||||||
|
Use /openspec:work to start on the next change.
|
||||||
|
```
|
||||||
|
|
||||||
|
7. **Show OpenSpec view**
|
||||||
|
- Run `openspec view` to show updated dashboard
|
||||||
|
- Confirms change is archived and specs are updated
|
||||||
|
|
||||||
|
**Error Handling**
|
||||||
|
|
||||||
|
- If no active change: Guide user to use /openspec:work
|
||||||
|
- If openspec archive fails: Show error and suggest checking change validity
|
||||||
|
- If CHANGELOG.md doesn't exist: Create it with standard format
|
||||||
|
- If active.json clear fails: Warn but continue (non-critical)
|
||||||
|
|
||||||
|
**Example Usage**
|
||||||
|
|
||||||
|
**Scenario 1: All tasks complete**
|
||||||
|
|
||||||
|
```txt
|
||||||
|
User: /openspec:done
|
||||||
|
Assistant: [Checks tasks.md - all complete ✓]
|
||||||
|
Would you like to save a final checkpoint? (y/n)
|
||||||
|
User: y
|
||||||
|
Assistant: [Runs checkpoint flow]
|
||||||
|
[Runs openspec archive add-dark-mode]
|
||||||
|
[Clears active.json]
|
||||||
|
✓ Change archived successfully!
|
||||||
|
[Shows next steps]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Scenario 2: Tasks incomplete**
|
||||||
|
|
||||||
|
```txt
|
||||||
|
User: /openspec:done
|
||||||
|
Assistant: ⚠️ Not all tasks are complete!
|
||||||
|
|
||||||
|
Remaining tasks: 3
|
||||||
|
- [ ] Add localStorage persistence
|
||||||
|
- [ ] Write tests for theme toggle
|
||||||
|
- [ ] Update documentation
|
||||||
|
|
||||||
|
Options:
|
||||||
|
1. Continue anyway (not recommended)
|
||||||
|
2. Cancel and finish tasks
|
||||||
|
3. Save checkpoint and resume later
|
||||||
|
|
||||||
|
What would you like to do?
|
||||||
|
User: 2
|
||||||
|
Assistant: Cancelled. Use /openspec:status to see remaining work.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes**
|
||||||
|
|
||||||
|
- This command wraps `openspec archive` with pre/post checks
|
||||||
|
- Clears active.json so SessionStart hook doesn't load old context
|
||||||
|
- Encourages final checkpoint for documentation
|
||||||
|
- Guides user through commit process
|
||||||
|
- Ensures clean state for starting next change
|
||||||
|
|
||||||
|
<!-- OPENSPEC:END -->
|
||||||
145
commands/openspec/init.md
Normal file
145
commands/openspec/init.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
---
|
||||||
|
name: openspec:init
|
||||||
|
description: Initialize OpenSpec directory structure in your project without duplicating slash commands.
|
||||||
|
category: openspec
|
||||||
|
tags: [openspec, setup, init]
|
||||||
|
---
|
||||||
|
|
||||||
|
# OpenSpec Init Command
|
||||||
|
|
||||||
|
Initialize OpenSpec directory structure in your project without duplicating slash commands.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Creates the `openspec/` directory structure (specs, changes, AGENTS.md, project.md) while avoiding conflicts with the workflow plugin's enhanced OpenSpec commands.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
This command should be run **once per project** when you first want to use OpenSpec.
|
||||||
|
|
||||||
|
## What This Command Does
|
||||||
|
|
||||||
|
1. **Validates openspec CLI is installed**
|
||||||
|
- Check if `openspec` command is available
|
||||||
|
- If not, provide installation instructions
|
||||||
|
|
||||||
|
2. **Runs `openspec init --tools none`**
|
||||||
|
- Creates `openspec/` directory structure
|
||||||
|
- Creates `openspec/AGENTS.md` (OpenSpec workflow instructions)
|
||||||
|
- Creates `openspec/project.md` (project context template)
|
||||||
|
- Creates `openspec/changes/` (for proposals)
|
||||||
|
- Creates `openspec/specs/` (for specifications)
|
||||||
|
- Creates root `AGENTS.md` (agent instructions)
|
||||||
|
- Does NOT create `.claude/commands/` (prevents duplication)
|
||||||
|
|
||||||
|
3. **Explains next steps**
|
||||||
|
- How to fill out `openspec/project.md`
|
||||||
|
- How to use OpenSpec slash commands from this plugin
|
||||||
|
- How to create first change proposal
|
||||||
|
|
||||||
|
## Why `--tools none`?
|
||||||
|
|
||||||
|
The workflow plugin provides **enhanced OpenSpec commands** with additional context and customization:
|
||||||
|
|
||||||
|
- `/openspec:proposal` - Create new change proposals
|
||||||
|
- `/openspec:work` - Start working on a proposal with full context loading
|
||||||
|
- `/openspec:apply` - Implement approved proposals with task tracking
|
||||||
|
- `/openspec:checkpoint` - Save progress and context
|
||||||
|
- `/openspec:status` - Show current proposal status
|
||||||
|
- `/openspec:done` - Complete and prepare for archiving
|
||||||
|
- `/openspec:archive` - Archive completed changes
|
||||||
|
|
||||||
|
Using `--tools none` prevents OpenSpec CLI from creating duplicate basic commands, ensuring you get the full enhanced experience from this plugin.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get current working directory first for cleaner checks
|
||||||
|
PROJECT_DIR=$(pwd)
|
||||||
|
|
||||||
|
# Check if already initialized - exit early with helpful message
|
||||||
|
if [ -d "$PROJECT_DIR/openspec" ]; then
|
||||||
|
echo "✅ OpenSpec is already initialized in this project!"
|
||||||
|
echo ""
|
||||||
|
echo "📂 Current structure:"
|
||||||
|
echo " openspec/"
|
||||||
|
echo " ├── AGENTS.md"
|
||||||
|
echo " ├── project.md"
|
||||||
|
echo " ├── changes/"
|
||||||
|
echo " └── specs/"
|
||||||
|
echo ""
|
||||||
|
echo "💡 Useful commands:"
|
||||||
|
echo " /openspec:update - Update instruction files to latest"
|
||||||
|
echo " /openspec:proposal - Create a new change proposal"
|
||||||
|
echo " /openspec:status - Check current proposal status"
|
||||||
|
echo ""
|
||||||
|
echo "🔧 To reinitialize (advanced):"
|
||||||
|
echo " 1. Remove openspec/ directory manually"
|
||||||
|
echo " 2. Run /openspec:init again"
|
||||||
|
echo ""
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if openspec is installed
|
||||||
|
if ! command -v openspec &> /dev/null; then
|
||||||
|
echo "Error: openspec CLI not found"
|
||||||
|
echo ""
|
||||||
|
echo "Install with:"
|
||||||
|
echo " npm install -g @jsdocs-io/openspec"
|
||||||
|
echo " # or"
|
||||||
|
echo " pnpm add -g @jsdocs-io/openspec"
|
||||||
|
echo " # or"
|
||||||
|
echo " yarn global add @jsdocs-io/openspec"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Initialize OpenSpec without tool-specific commands
|
||||||
|
echo "Initializing OpenSpec in: $PROJECT_DIR"
|
||||||
|
echo ""
|
||||||
|
openspec init "$PROJECT_DIR" --tools none
|
||||||
|
|
||||||
|
# Check if successful
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "✅ OpenSpec initialized successfully!"
|
||||||
|
echo ""
|
||||||
|
echo "📂 Created structure:"
|
||||||
|
echo " openspec/"
|
||||||
|
echo " ├── AGENTS.md # OpenSpec workflow instructions"
|
||||||
|
echo " ├── project.md # Project context (fill this out!)"
|
||||||
|
echo " ├── changes/ # Change proposals"
|
||||||
|
echo " └── specs/ # Specifications"
|
||||||
|
echo ""
|
||||||
|
echo "🎯 Next steps:"
|
||||||
|
echo ""
|
||||||
|
echo "1. Fill out project context:"
|
||||||
|
echo " 'Please read openspec/project.md and help me fill it out"
|
||||||
|
echo " with details about my project, tech stack, and conventions'"
|
||||||
|
echo ""
|
||||||
|
echo "2. Create your first proposal:"
|
||||||
|
echo " 'I want to add [FEATURE]. Please create an OpenSpec change"
|
||||||
|
echo " proposal using /openspec:proposal'"
|
||||||
|
echo ""
|
||||||
|
echo "3. Available commands from workflow plugin:"
|
||||||
|
echo " /openspec:proposal - Create new proposal"
|
||||||
|
echo " /openspec:work - Start working on proposal"
|
||||||
|
echo " /openspec:apply - Implement approved proposal"
|
||||||
|
echo " /openspec:checkpoint - Save progress"
|
||||||
|
echo " /openspec:status - Show current status"
|
||||||
|
echo " /openspec:done - Mark proposal complete"
|
||||||
|
echo " /openspec:archive - Archive completed proposal"
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo "❌ OpenSpec initialization failed"
|
||||||
|
echo "Check the error messages above"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **Run once per project** - Not needed in every session
|
||||||
|
- **Safe to re-run** - Will ask before overwriting existing structure
|
||||||
|
- **No conflicts** - Won't create duplicate commands (uses `--tools none`)
|
||||||
|
- **Enhanced commands** - You get our customized OpenSpec workflow from the plugin
|
||||||
32
commands/openspec/proposal.md
Normal file
32
commands/openspec/proposal.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
name: openspec:proposal
|
||||||
|
description: Scaffold a new OpenSpec change and validate strictly.
|
||||||
|
category: openspec
|
||||||
|
tags: [openspec, change]
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- OPENSPEC:START -->
|
||||||
|
|
||||||
|
**Guardrails**
|
||||||
|
|
||||||
|
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
|
||||||
|
- Keep changes tightly scoped to the requested outcome.
|
||||||
|
- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications.
|
||||||
|
- Identify any vague or ambiguous details and ask the necessary follow-up questions before editing files.
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
|
||||||
|
1. Review `openspec/project.md`, run `openspec list` and `openspec list --specs`, and inspect related code or docs (e.g., via `rg`/`ls`) to ground the proposal in current behavior; note any gaps that require clarification.
|
||||||
|
2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, and `design.md` (when needed) under `openspec/changes/<id>/`.
|
||||||
|
3. Map the change into concrete capabilities or requirements, breaking multi-scope efforts into distinct spec deltas with clear relationships and sequencing.
|
||||||
|
4. Capture architectural reasoning in `design.md` when the solution spans multiple systems, introduces new patterns, or demands trade-off discussion before committing to specs.
|
||||||
|
5. Draft spec deltas in `changes/<id>/specs/<capability>/spec.md` (one folder per capability) using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement and cross-reference related capabilities when relevant.
|
||||||
|
6. Draft `tasks.md` as an ordered list of small, verifiable work items that deliver user-visible progress, include validation (tests, tooling), and highlight dependencies or parallelizable work.
|
||||||
|
7. Validate with `openspec validate <id> --strict` and resolve every issue before sharing the proposal.
|
||||||
|
|
||||||
|
**Reference**
|
||||||
|
|
||||||
|
- Use `openspec show <id> --json --deltas-only` or `openspec show <spec> --type spec` to inspect details when validation fails.
|
||||||
|
- Search existing requirements with `rg -n "Requirement:|Scenario:" openspec/specs` before writing new ones.
|
||||||
|
- Explore the codebase with `rg <keyword>`, `ls`, or direct file reads so proposals align with current implementation realities.
|
||||||
|
<!-- OPENSPEC:END -->
|
||||||
104
commands/openspec/status.md
Normal file
104
commands/openspec/status.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
---
|
||||||
|
name: openspec:status
|
||||||
|
description: Show current OpenSpec change status and progress.
|
||||||
|
category: openspec
|
||||||
|
tags: [openspec, status, progress]
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- OPENSPEC:START -->
|
||||||
|
|
||||||
|
**Purpose**
|
||||||
|
|
||||||
|
This command shows the current state of your OpenSpec work:
|
||||||
|
|
||||||
|
- Which change you're actively working on
|
||||||
|
- Progress on tasks (completed vs remaining)
|
||||||
|
- Time since you started and last checkpoint
|
||||||
|
- Quick access to the OpenSpec dashboard
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
|
||||||
|
1. **Check for active change**
|
||||||
|
- Read `openspec/active.json`
|
||||||
|
- If file doesn't exist or is empty: "No active change. Use /openspec:work to start working on a change."
|
||||||
|
- Exit if no active change
|
||||||
|
|
||||||
|
2. **Parse active change data**
|
||||||
|
- Extract: `change` (change ID), `started` (timestamp), `lastCheckpoint` (timestamp)
|
||||||
|
- Calculate time elapsed since started and last checkpoint
|
||||||
|
|
||||||
|
3. **Run OpenSpec dashboard**
|
||||||
|
- Execute `openspec view` to get current state of all changes
|
||||||
|
- This shows the progress bars and task counts
|
||||||
|
|
||||||
|
4. **Load task progress**
|
||||||
|
- Read `openspec/changes/<change-id>/tasks.md`
|
||||||
|
- Count total tasks (lines starting with `- [ ]` or `- [x]`)
|
||||||
|
- Count completed tasks (lines starting with `- [x]`)
|
||||||
|
- Calculate percentage: `(completed / total) * 100`
|
||||||
|
|
||||||
|
5. **Display status summary**
|
||||||
|
|
||||||
|
```txt
|
||||||
|
📊 OpenSpec Status
|
||||||
|
|
||||||
|
Active Change: <change-id>
|
||||||
|
Started: <X hours/days ago>
|
||||||
|
Last Checkpoint: <Y minutes/hours ago>
|
||||||
|
|
||||||
|
Progress:
|
||||||
|
├─ Tasks: <completed>/<total> (<percentage>%)
|
||||||
|
├─ Status: <In Progress|Blocked|Ready for Review>
|
||||||
|
└─ Next: <first incomplete task from tasks.md>
|
||||||
|
|
||||||
|
Recent Context (from design.md):
|
||||||
|
<last 5-10 lines of design.md to show recent decisions/progress>
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
- /openspec:checkpoint - Save current progress
|
||||||
|
- /openspec:work - Switch to different change
|
||||||
|
- /openspec:done - Complete and archive
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Show OpenSpec dashboard**
|
||||||
|
- Output result of `openspec view` for full context
|
||||||
|
|
||||||
|
**Error Handling**
|
||||||
|
|
||||||
|
- If active.json exists but change directory missing: "Active change '<change-id>' not found. It may have been archived. Use /openspec:work to select a new change."
|
||||||
|
- If tasks.md missing: Show "0/0 tasks (no tasks.md found)"
|
||||||
|
- If design.md missing: Skip "Recent Context" section
|
||||||
|
|
||||||
|
**Example Usage**
|
||||||
|
|
||||||
|
```txt
|
||||||
|
User: /openspec:status
|
||||||
|
Assistant:
|
||||||
|
📊 OpenSpec Status
|
||||||
|
|
||||||
|
Active Change: add-dark-mode
|
||||||
|
Started: 2 hours ago
|
||||||
|
Last Checkpoint: 15 minutes ago
|
||||||
|
|
||||||
|
Progress:
|
||||||
|
├─ Tasks: 8/12 (67%)
|
||||||
|
├─ Status: In Progress
|
||||||
|
└─ Next: Implement theme toggle in settings page
|
||||||
|
|
||||||
|
Recent Context (from design.md):
|
||||||
|
- Created ThemeContext with light/dark modes
|
||||||
|
- Added theme provider to root layout
|
||||||
|
- Implemented CSS variable system for colors
|
||||||
|
- Need to add persistence with localStorage
|
||||||
|
|
||||||
|
[openspec view output shown]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes**
|
||||||
|
|
||||||
|
- This command is read-only - it doesn't modify anything
|
||||||
|
- Useful for quick progress checks without reading all files
|
||||||
|
- Shows "at a glance" summary before diving into details
|
||||||
|
- Complements `openspec view` with active change focus
|
||||||
|
|
||||||
|
<!-- OPENSPEC:END -->
|
||||||
122
commands/openspec/update.md
Normal file
122
commands/openspec/update.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
---
|
||||||
|
name: openspec:update
|
||||||
|
description: Update OpenSpec instruction files to the latest version without affecting slash commands.
|
||||||
|
category: openspec
|
||||||
|
tags: [openspec, setup, update]
|
||||||
|
---
|
||||||
|
|
||||||
|
# OpenSpec Update Command
|
||||||
|
|
||||||
|
Update OpenSpec instruction files to the latest version without affecting slash commands.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Refreshes OpenSpec workflow documentation (AGENTS.md files) when the OpenSpec CLI is updated, ensuring you have the latest best practices and patterns.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Run this command after updating the OpenSpec CLI to get the latest instruction files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Update OpenSpec CLI first
|
||||||
|
npm update -g @jsdocs-io/openspec
|
||||||
|
|
||||||
|
# Then refresh instruction files
|
||||||
|
/openspec-update
|
||||||
|
```
|
||||||
|
|
||||||
|
## What This Command Does
|
||||||
|
|
||||||
|
1. **Validates openspec CLI is installed**
|
||||||
|
- Checks if `openspec` command is available
|
||||||
|
- Shows current version
|
||||||
|
|
||||||
|
2. **Runs `openspec update`**
|
||||||
|
- Updates `openspec/AGENTS.md` (OpenSpec workflow instructions)
|
||||||
|
- Updates root `AGENTS.md` (if it exists)
|
||||||
|
- **Does NOT touch slash commands** (we maintain our own enhanced versions)
|
||||||
|
|
||||||
|
3. **Shows what was updated**
|
||||||
|
- Lists files that changed
|
||||||
|
- Explains what's new (if possible)
|
||||||
|
|
||||||
|
## Safety
|
||||||
|
|
||||||
|
This command is **safe to run repeatedly**:
|
||||||
|
|
||||||
|
- ✅ Updates instruction files only
|
||||||
|
- ✅ Won't override workflow plugin commands
|
||||||
|
- ✅ Won't modify your specs or changes
|
||||||
|
- ✅ Won't affect project configuration
|
||||||
|
|
||||||
|
## When to Run
|
||||||
|
|
||||||
|
- After updating OpenSpec CLI to a new version
|
||||||
|
- When you want the latest OpenSpec patterns and best practices
|
||||||
|
- After reading OpenSpec release notes mentioning instruction updates
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check if openspec is installed
|
||||||
|
if ! command -v openspec &> /dev/null; then
|
||||||
|
echo "Error: openspec CLI not found"
|
||||||
|
echo ""
|
||||||
|
echo "Install with:"
|
||||||
|
echo " npm install -g @jsdocs-io/openspec"
|
||||||
|
echo " # or"
|
||||||
|
echo " pnpm add -g @jsdocs-io/openspec"
|
||||||
|
echo " # or"
|
||||||
|
echo " yarn global add @jsdocs-io/openspec"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Show current version
|
||||||
|
OPENSPEC_VERSION=$(openspec --version 2>&1 || echo "unknown")
|
||||||
|
echo "OpenSpec CLI version: $OPENSPEC_VERSION"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Get current working directory
|
||||||
|
PROJECT_DIR=$(pwd)
|
||||||
|
|
||||||
|
# Check if initialized
|
||||||
|
if [ ! -d "$PROJECT_DIR/openspec" ]; then
|
||||||
|
echo "Error: OpenSpec not initialized in this project"
|
||||||
|
echo ""
|
||||||
|
echo "Run /openspec-init first to set up OpenSpec"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Update instruction files
|
||||||
|
echo "Updating OpenSpec instruction files in: $PROJECT_DIR"
|
||||||
|
echo ""
|
||||||
|
openspec update "$PROJECT_DIR"
|
||||||
|
|
||||||
|
# Check if successful
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "✅ OpenSpec instruction files updated successfully!"
|
||||||
|
echo ""
|
||||||
|
echo "📝 Updated files:"
|
||||||
|
echo " openspec/AGENTS.md # Latest workflow instructions"
|
||||||
|
echo " AGENTS.md # Updated agent guidance"
|
||||||
|
echo ""
|
||||||
|
echo "💡 Note:"
|
||||||
|
echo " Slash commands (/openspec:*) are managed by the workflow plugin"
|
||||||
|
echo " and are NOT affected by this update. You're using enhanced versions"
|
||||||
|
echo " customized for Claude Code."
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo "❌ Update failed"
|
||||||
|
echo "Check the error messages above"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **Safe to run anytime** - Won't break existing work
|
||||||
|
- **No command conflicts** - Won't override workflow plugin commands
|
||||||
|
- **Version aware** - Get instructions matching your CLI version
|
||||||
|
- **Preserves customization** - Your specs and changes untouched
|
||||||
101
commands/openspec/work.md
Normal file
101
commands/openspec/work.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
---
|
||||||
|
name: openspec:work
|
||||||
|
description: Start working on an OpenSpec change with full context loading.
|
||||||
|
category: openspec
|
||||||
|
tags: [openspec, work, context]
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- OPENSPEC:START -->
|
||||||
|
|
||||||
|
**Purpose**
|
||||||
|
|
||||||
|
This command helps you start or resume work on an OpenSpec change proposal. It:
|
||||||
|
|
||||||
|
- Shows the OpenSpec dashboard with all changes
|
||||||
|
- Loads the change context (proposal, design, tasks)
|
||||||
|
- Tracks the active change for session persistence
|
||||||
|
- Enables automatic context loading on session resume
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
|
||||||
|
1. **Show OpenSpec dashboard**
|
||||||
|
- Run `openspec view` to display all changes with progress
|
||||||
|
- Output the dashboard so user can see status
|
||||||
|
|
||||||
|
2. **Select change to work on**
|
||||||
|
- Ask user: "Which change would you like to work on? (provide change ID)"
|
||||||
|
- Change ID is the folder name in `openspec/changes/`
|
||||||
|
- Example: `add-dark-mode`, `fix-auth-bug`, `refactor-api`
|
||||||
|
|
||||||
|
3. **Verify change exists**
|
||||||
|
- Check that `openspec/changes/<change-id>/` directory exists
|
||||||
|
- If not found, show error: "Change '<change-id>' not found. Run 'openspec view' to see available changes."
|
||||||
|
- Exit if not found
|
||||||
|
|
||||||
|
4. **Load change context**
|
||||||
|
- Read and display key files in this order:
|
||||||
|
- `openspec/changes/<change-id>/proposal.md` - The WHY (goals, motivation)
|
||||||
|
- `openspec/changes/<change-id>/design.md` - The HOW (living doc with approach)
|
||||||
|
- `openspec/changes/<change-id>/tasks.md` - The WHAT (checklist of work)
|
||||||
|
- If design.md doesn't exist, note: "No design.md yet. Create one to track your approach and decisions."
|
||||||
|
|
||||||
|
5. **Update active change tracker**
|
||||||
|
- Write to `openspec/active.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"change": "<change-id>",
|
||||||
|
"started": "<ISO 8601 timestamp>",
|
||||||
|
"lastCheckpoint": "<ISO 8601 timestamp>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Use JavaScript/TypeScript Date: `new Date().toISOString()`
|
||||||
|
|
||||||
|
6. **Confirm and guide next steps**
|
||||||
|
- Show success message:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
✓ Now working on: <change-id>
|
||||||
|
|
||||||
|
Context loaded:
|
||||||
|
- Proposal: <brief summary from proposal.md>
|
||||||
|
- Tasks: <X> remaining, <Y> completed
|
||||||
|
|
||||||
|
Next steps:
|
||||||
|
1. Review the proposal and design above
|
||||||
|
2. Work through tasks in tasks.md sequentially
|
||||||
|
3. Use /openspec:checkpoint to save progress
|
||||||
|
4. Use /openspec:status to check progress anytime
|
||||||
|
5. Use /openspec:done when all tasks complete
|
||||||
|
|
||||||
|
The SessionStart hook will automatically load this context if you resume later.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Handling**
|
||||||
|
|
||||||
|
- If `openspec` CLI not found: "OpenSpec CLI not installed. Run 'npm install -g @fission-codes/openspec'"
|
||||||
|
- If no changes exist: "No OpenSpec changes found. Create one with /openspec:proposal"
|
||||||
|
- If `openspec/` directory doesn't exist: Show error (should be created by 'openspec init')
|
||||||
|
- If active.json write fails: Show error but continue (non-critical)
|
||||||
|
|
||||||
|
**Example Usage**
|
||||||
|
|
||||||
|
```txt
|
||||||
|
User: /openspec:work
|
||||||
|
Assistant: [Runs openspec view, shows dashboard]
|
||||||
|
Which change would you like to work on? (provide change ID)
|
||||||
|
User: add-dark-mode
|
||||||
|
Assistant: [Loads proposal.md, design.md, tasks.md]
|
||||||
|
[Updates active.json]
|
||||||
|
[Shows success message with context summary]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes**
|
||||||
|
|
||||||
|
- This command wraps `openspec view` and adds context loading
|
||||||
|
- The active.json file enables session resume via SessionStart hook
|
||||||
|
- design.md serves as the "living doc" (Reddit's context.md equivalent)
|
||||||
|
- Use /openspec:checkpoint frequently to update design.md with progress
|
||||||
|
|
||||||
|
<!-- OPENSPEC:END -->
|
||||||
236
hooks/branch-name-validator.ts
Executable file
236
hooks/branch-name-validator.ts
Executable file
@@ -0,0 +1,236 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Branch Name Validator Hook
|
||||||
|
*
|
||||||
|
* Enforces conventional commit prefixes for branch names (GitHub Flow).
|
||||||
|
* Valid patterns: feat/*, fix/*, chore/*, docs/*, test/*, refactor/*, perf/*
|
||||||
|
* Allows main/master (protected branches).
|
||||||
|
*
|
||||||
|
* Performance target: <50ms (ADR-0010)
|
||||||
|
*
|
||||||
|
* @see {@link https://github.com/jbabin91/super-claude} for documentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { checkPerformance, formatError, parseStdin } from './utils/index.js';
|
||||||
|
import {
|
||||||
|
checkHookEnabled,
|
||||||
|
getHookConfig,
|
||||||
|
} from './utils/super-claude-config-loader.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract branch name from git checkout command
|
||||||
|
*
|
||||||
|
* @param command Git command string
|
||||||
|
* @returns Branch name or null if not a checkout -b command
|
||||||
|
*/
|
||||||
|
function extractBranchName(command: string): string | null {
|
||||||
|
// Match: git checkout -b <branch-name>
|
||||||
|
const match = /git\s+checkout\s+-b\s+([^\s]+)/.exec(command);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate branch name follows conventional commit pattern
|
||||||
|
*
|
||||||
|
* @param branchName Branch name to validate
|
||||||
|
* @param allowedPrefixes List of valid prefixes
|
||||||
|
* @param allowedBranches Set of branches allowed without prefix
|
||||||
|
* @returns true if valid, false otherwise
|
||||||
|
*/
|
||||||
|
function isValidBranchName(
|
||||||
|
branchName: string,
|
||||||
|
allowedPrefixes: string[],
|
||||||
|
allowedBranches: Set<string>,
|
||||||
|
): boolean {
|
||||||
|
// Allow protected branches
|
||||||
|
if (allowedBranches.has(branchName)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if follows pattern: <type>/<description>
|
||||||
|
const pattern = new RegExp(`^(${allowedPrefixes.join('|')})/[a-z0-9-]+$`);
|
||||||
|
return pattern.test(branchName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get suggested branch name from invalid name
|
||||||
|
*
|
||||||
|
* @param branchName Invalid branch name
|
||||||
|
* @param allowedPrefixes List of valid prefixes
|
||||||
|
* @returns Suggested valid branch name
|
||||||
|
*/
|
||||||
|
function suggestBranchName(
|
||||||
|
branchName: string,
|
||||||
|
allowedPrefixes: string[],
|
||||||
|
): string {
|
||||||
|
// Try to extract a valid prefix if present
|
||||||
|
for (const prefix of allowedPrefixes) {
|
||||||
|
if (branchName.toLowerCase().includes(prefix)) {
|
||||||
|
const description = branchName
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(new RegExp(`^${prefix}[/-]?`), '')
|
||||||
|
.replaceAll(/[^a-z0-9-]/g, '-')
|
||||||
|
.replaceAll(/-+/g, '-')
|
||||||
|
.replaceAll(/^-|-$/g, '');
|
||||||
|
|
||||||
|
return description ? `${prefix}/${description}` : `${prefix}/description`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default suggestion
|
||||||
|
return `feat/${branchName.toLowerCase().replaceAll(/[^a-z0-9-]/g, '-')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format blocking message
|
||||||
|
*
|
||||||
|
* @param branchName Invalid branch name
|
||||||
|
* @param allowedPrefixes List of valid prefixes
|
||||||
|
* @returns Formatted error message
|
||||||
|
*/
|
||||||
|
function formatBlockMessage(
|
||||||
|
branchName: string,
|
||||||
|
allowedPrefixes: string[],
|
||||||
|
): string {
|
||||||
|
const suggested = suggestBranchName(branchName, allowedPrefixes);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'',
|
||||||
|
'═'.repeat(70),
|
||||||
|
'❌ INVALID BRANCH NAME',
|
||||||
|
'═'.repeat(70),
|
||||||
|
'',
|
||||||
|
`Branch name: ${branchName}`,
|
||||||
|
'',
|
||||||
|
'GitHub Flow + Conventional Commits requires:',
|
||||||
|
' <type>/<description>',
|
||||||
|
'',
|
||||||
|
'Valid types:',
|
||||||
|
` ${allowedPrefixes.join(', ')}`,
|
||||||
|
'',
|
||||||
|
'Description rules:',
|
||||||
|
' • Use kebab-case (lowercase with hyphens)',
|
||||||
|
' • Be descriptive and concise',
|
||||||
|
' • Use letters, numbers, and hyphens only',
|
||||||
|
'',
|
||||||
|
'✅ Valid examples:',
|
||||||
|
' feat/user-authentication',
|
||||||
|
' fix/memory-leak-in-parser',
|
||||||
|
' docs/api-reference',
|
||||||
|
' test/validate-branch-names',
|
||||||
|
' refactor/simplify-hooks',
|
||||||
|
'',
|
||||||
|
'❌ Invalid examples:',
|
||||||
|
' feature/auth (use "feat" not "feature")',
|
||||||
|
' fix_bug (use "/" not "_")',
|
||||||
|
' MyFeature (no type prefix)',
|
||||||
|
' feat/Fix Bug (use kebab-case)',
|
||||||
|
'',
|
||||||
|
`💡 Suggested: git checkout -b ${suggested}`,
|
||||||
|
'',
|
||||||
|
'═'.repeat(70),
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main hook execution
|
||||||
|
*/
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const input = await parseStdin();
|
||||||
|
|
||||||
|
// Check if hook is enabled
|
||||||
|
checkHookEnabled(input.cwd, 'workflow', 'branchNameValidator');
|
||||||
|
|
||||||
|
// Get hook configuration
|
||||||
|
const config = getHookConfig(input.cwd, 'workflow', 'branchNameValidator');
|
||||||
|
const allowedPrefixes = (config.allowedPrefixes as string[]) ?? [
|
||||||
|
'feat',
|
||||||
|
'fix',
|
||||||
|
'chore',
|
||||||
|
'docs',
|
||||||
|
'test',
|
||||||
|
'refactor',
|
||||||
|
'perf',
|
||||||
|
'build',
|
||||||
|
'ci',
|
||||||
|
'revert',
|
||||||
|
'style',
|
||||||
|
];
|
||||||
|
const allowedBranchList = (config.allowedBranches as string[]) ?? [
|
||||||
|
'main',
|
||||||
|
'master',
|
||||||
|
'develop',
|
||||||
|
];
|
||||||
|
const allowedBranches = new Set(allowedBranchList);
|
||||||
|
|
||||||
|
// Only run for Bash tool
|
||||||
|
if (input.tool_name !== 'Bash') {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract command from tool input
|
||||||
|
const toolInput = input.tool_input!;
|
||||||
|
const command = toolInput?.command as string | undefined;
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only check git checkout -b commands
|
||||||
|
if (!command.includes('git checkout -b')) {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract branch name
|
||||||
|
const branchName = extractBranchName(command);
|
||||||
|
|
||||||
|
if (!branchName) {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('[DEBUG] branch-name-validator: Checking branch name');
|
||||||
|
console.error(`[DEBUG] Branch name: ${branchName}`);
|
||||||
|
console.error(`[DEBUG] Allowed prefixes: ${allowedPrefixes.join(', ')}`);
|
||||||
|
console.error(
|
||||||
|
`[DEBUG] Allowed branches: ${[...allowedBranches].join(', ')}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Validate branch name
|
||||||
|
if (!isValidBranchName(branchName, allowedPrefixes, allowedBranches)) {
|
||||||
|
// Block the operation
|
||||||
|
console.error('[DEBUG] BLOCKING - invalid branch name');
|
||||||
|
|
||||||
|
const output = {
|
||||||
|
hookSpecificOutput: {
|
||||||
|
hookEventName: 'PreToolUse',
|
||||||
|
permissionDecision: 'deny',
|
||||||
|
permissionDecisionReason: formatBlockMessage(
|
||||||
|
branchName,
|
||||||
|
allowedPrefixes,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(JSON.stringify(output));
|
||||||
|
checkPerformance(startTime, 50, 'branch-name-validator');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow the operation - valid branch name
|
||||||
|
console.error('[DEBUG] ALLOWING - valid branch name');
|
||||||
|
checkPerformance(startTime, 50, 'branch-name-validator');
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(formatError(error, 'branch-name-validator'));
|
||||||
|
// On hook error, don't block the operation
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
await main();
|
||||||
235
hooks/git-commit-guard.ts
Executable file
235
hooks/git-commit-guard.ts
Executable file
@@ -0,0 +1,235 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Git Commit Guard Hook
|
||||||
|
*
|
||||||
|
* Prevents direct commits/pushes to protected branches (main).
|
||||||
|
* Enforces feature branch workflow - commits should happen on feature branches.
|
||||||
|
* Use bypass environment variable for emergencies: SKIP_COMMIT_GUARD=true
|
||||||
|
*
|
||||||
|
* Performance target: <50ms (ADR-0010)
|
||||||
|
*
|
||||||
|
* @see {@link https://github.com/jbabin91/super-claude} for documentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
|
||||||
|
import { checkPerformance, formatError, parseStdin } from './utils/index.js';
|
||||||
|
import {
|
||||||
|
checkHookEnabled,
|
||||||
|
getHookConfig,
|
||||||
|
} from './utils/super-claude-config-loader.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current git branch
|
||||||
|
*
|
||||||
|
* @param cwd Current working directory
|
||||||
|
* @returns Current branch name or null if not in git repo
|
||||||
|
*/
|
||||||
|
function getCurrentBranch(cwd: string): string | null {
|
||||||
|
try {
|
||||||
|
const branch = execSync('git branch --show-current', {
|
||||||
|
cwd,
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
}).trim();
|
||||||
|
return branch || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect if Bash command is a git commit or push
|
||||||
|
*
|
||||||
|
* @param command Bash command string
|
||||||
|
* @returns true if commit/push command
|
||||||
|
*/
|
||||||
|
function isGitCommitOrPush(command: string): boolean {
|
||||||
|
const patterns = [
|
||||||
|
/\bgit\s+commit\b/i,
|
||||||
|
/\bgit\s+push\b/i,
|
||||||
|
/\bgit\s+ci\b/i, // Common commit alias
|
||||||
|
];
|
||||||
|
|
||||||
|
return patterns.some((pattern) => pattern.test(command));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if command targets a protected branch
|
||||||
|
*
|
||||||
|
* @param command Git command
|
||||||
|
* @param currentBranch Current branch name
|
||||||
|
* @param protectedBranches List of protected branches
|
||||||
|
* @returns true if targeting protected branch
|
||||||
|
*/
|
||||||
|
function targetsProtectedBranch(
|
||||||
|
command: string,
|
||||||
|
currentBranch: string | null,
|
||||||
|
protectedBranches: string[],
|
||||||
|
): boolean {
|
||||||
|
// Check if push command explicitly targets protected branch
|
||||||
|
for (const branch of protectedBranches) {
|
||||||
|
if (
|
||||||
|
command.includes(`origin ${branch}`) ||
|
||||||
|
command.includes(`origin/${branch}`)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if current branch is protected
|
||||||
|
if (currentBranch && protectedBranches.includes(currentBranch)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format blocking message
|
||||||
|
*
|
||||||
|
* @param currentBranch Current branch name
|
||||||
|
* @param command Git command that was blocked
|
||||||
|
* @param protectedBranches List of protected branches
|
||||||
|
* @returns Formatted error message
|
||||||
|
*/
|
||||||
|
function formatBlockMessage(
|
||||||
|
currentBranch: string | null,
|
||||||
|
command: string,
|
||||||
|
protectedBranches: string[],
|
||||||
|
): string {
|
||||||
|
return [
|
||||||
|
'',
|
||||||
|
'═'.repeat(70),
|
||||||
|
'⚠️ DIRECT COMMIT/PUSH TO PROTECTED BRANCH BLOCKED',
|
||||||
|
'═'.repeat(70),
|
||||||
|
'',
|
||||||
|
`Protected branches: ${protectedBranches.join(', ')}`,
|
||||||
|
`Current branch: ${currentBranch ?? 'unknown'}`,
|
||||||
|
`Blocked command: ${command}`,
|
||||||
|
'',
|
||||||
|
'Feature Branch Workflow:',
|
||||||
|
'',
|
||||||
|
' 1. Create a feature branch:',
|
||||||
|
' git checkout -b feat/your-feature',
|
||||||
|
'',
|
||||||
|
' 2. Make commits on your feature branch:',
|
||||||
|
' git commit -m "feat: add new feature"',
|
||||||
|
'',
|
||||||
|
' 3. Push feature branch:',
|
||||||
|
' git push origin feat/your-feature',
|
||||||
|
'',
|
||||||
|
' 4. Create pull request:',
|
||||||
|
' gh pr create',
|
||||||
|
'',
|
||||||
|
' 5. After approval, merge via PR',
|
||||||
|
'',
|
||||||
|
'To bypass this guard (emergencies only):',
|
||||||
|
' SKIP_COMMIT_GUARD=true git commit -m "..."',
|
||||||
|
' SKIP_COMMIT_GUARD=true git push',
|
||||||
|
'',
|
||||||
|
'═'.repeat(70),
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main hook execution
|
||||||
|
*/
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const input = await parseStdin();
|
||||||
|
|
||||||
|
// Check if hook is enabled and get config
|
||||||
|
checkHookEnabled(input.cwd, 'workflow', 'gitCommitGuard');
|
||||||
|
|
||||||
|
// Get hook configuration
|
||||||
|
const config = getHookConfig(input.cwd, 'workflow', 'gitCommitGuard');
|
||||||
|
const protectedBranches = (config.protectedBranches as string[]) ?? [
|
||||||
|
'main',
|
||||||
|
'master',
|
||||||
|
];
|
||||||
|
const bypassEnvVar = (config.bypassEnvVar as string) ?? 'SKIP_COMMIT_GUARD';
|
||||||
|
|
||||||
|
// Check for bypass flag
|
||||||
|
if (process.env[bypassEnvVar] === 'true') {
|
||||||
|
console.error(`[DEBUG] ${bypassEnvVar}=true - bypassing guard`);
|
||||||
|
checkPerformance(startTime, 50, 'git-commit-guard');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only run for Bash tool
|
||||||
|
if (input.tool_name !== 'Bash') {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract command from tool input
|
||||||
|
const toolInput = input.tool_input!;
|
||||||
|
const command = toolInput?.command as string | undefined;
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for bypass flag in command string
|
||||||
|
if (command.includes(`${bypassEnvVar}=true`)) {
|
||||||
|
console.error(
|
||||||
|
`[DEBUG] ${bypassEnvVar}=true in command - bypassing guard`,
|
||||||
|
);
|
||||||
|
checkPerformance(startTime, 50, 'git-commit-guard');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only check git commit/push commands
|
||||||
|
if (!isGitCommitOrPush(command)) {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current branch
|
||||||
|
const currentBranch = getCurrentBranch(input.cwd);
|
||||||
|
|
||||||
|
console.error('[DEBUG] git-commit-guard: Git operation detected');
|
||||||
|
console.error(`[DEBUG] Current branch: ${currentBranch}`);
|
||||||
|
console.error(`[DEBUG] Command: ${command}`);
|
||||||
|
console.error(
|
||||||
|
`[DEBUG] Protected branches: ${protectedBranches.join(', ')}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if targeting protected branch
|
||||||
|
if (targetsProtectedBranch(command, currentBranch, protectedBranches)) {
|
||||||
|
// Block the operation
|
||||||
|
console.error('[DEBUG] BLOCKING - targets protected branch');
|
||||||
|
|
||||||
|
const output = {
|
||||||
|
hookSpecificOutput: {
|
||||||
|
hookEventName: 'PreToolUse',
|
||||||
|
permissionDecision: 'deny',
|
||||||
|
permissionDecisionReason: formatBlockMessage(
|
||||||
|
currentBranch,
|
||||||
|
command,
|
||||||
|
protectedBranches,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(JSON.stringify(output));
|
||||||
|
checkPerformance(startTime, 50, 'git-commit-guard');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow the operation - not targeting protected branch
|
||||||
|
console.error('[DEBUG] ALLOWING - not targeting protected branch');
|
||||||
|
checkPerformance(startTime, 50, 'git-commit-guard');
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(formatError(error, 'git-commit-guard'));
|
||||||
|
// On hook error, don't block the operation
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
await main();
|
||||||
49
hooks/hooks.json
Normal file
49
hooks/hooks.json
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/session-checklist.ts"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Edit|Write",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/type-checker.ts"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/git-commit-guard.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/branch-name-validator.ts"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"UserPromptSubmit": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/skill-activation-prompt.ts"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": "Workflow plugin hooks for session checklists, type checking, commit guards, branch name validation, and skill auto-activation"
|
||||||
|
}
|
||||||
203
hooks/session-checklist.ts
Executable file
203
hooks/session-checklist.ts
Executable file
@@ -0,0 +1,203 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session Checklist Hook
|
||||||
|
*
|
||||||
|
* Displays quick project status at session start:
|
||||||
|
* - Git status (branch, staged files, recent commits)
|
||||||
|
* - Active OpenSpec changes
|
||||||
|
* - Quick command reference
|
||||||
|
*
|
||||||
|
* Performance target: <100ms (ADR-0010)
|
||||||
|
*
|
||||||
|
* @see {@link https://github.com/jbabin91/super-claude} for documentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
import { existsSync, readdirSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { checkPerformance, formatError, parseStdin } from './utils/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get git status information
|
||||||
|
*
|
||||||
|
* @param cwd Current working directory
|
||||||
|
* @returns Git status summary or null if not a git repo
|
||||||
|
*/
|
||||||
|
function getGitStatus(cwd: string): {
|
||||||
|
branch: string;
|
||||||
|
staged: number;
|
||||||
|
unstaged: number;
|
||||||
|
untracked: number;
|
||||||
|
} | null {
|
||||||
|
try {
|
||||||
|
// Get branch (also validates git repo)
|
||||||
|
const branch = execSync('git branch --show-current', {
|
||||||
|
cwd,
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
}).trim();
|
||||||
|
|
||||||
|
// Get status counts
|
||||||
|
const status = execSync('git status --porcelain', {
|
||||||
|
cwd,
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
let staged = 0;
|
||||||
|
let unstaged = 0;
|
||||||
|
let untracked = 0;
|
||||||
|
|
||||||
|
for (const line of status.split('\n')) {
|
||||||
|
if (!line) continue;
|
||||||
|
const x = line[0];
|
||||||
|
const y = line[1];
|
||||||
|
|
||||||
|
if (x !== ' ' && x !== '?') staged++;
|
||||||
|
if (y !== ' ' && y !== '?') unstaged++;
|
||||||
|
if (x === '?' && y === '?') untracked++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { branch, staged, unstaged, untracked };
|
||||||
|
} catch {
|
||||||
|
return null; // Not a git repo or git error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recent commits
|
||||||
|
*
|
||||||
|
* @param cwd Current working directory
|
||||||
|
* @param count Number of commits to fetch
|
||||||
|
* @returns Array of commit summaries
|
||||||
|
*/
|
||||||
|
function getRecentCommits(cwd: string, count = 3): string[] {
|
||||||
|
try {
|
||||||
|
const log = execSync(
|
||||||
|
`git log -n ${count} --pretty=format:"%h %s" --no-decorate`,
|
||||||
|
{ cwd, encoding: 'utf8' },
|
||||||
|
);
|
||||||
|
return log.split('\n').filter((line) => line.trim());
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get active OpenSpec changes
|
||||||
|
*
|
||||||
|
* @param cwd Current working directory
|
||||||
|
* @returns Array of change names
|
||||||
|
*/
|
||||||
|
function getActiveChanges(cwd: string): string[] {
|
||||||
|
const changesDir = path.join(cwd, 'openspec', 'changes');
|
||||||
|
|
||||||
|
if (!existsSync(changesDir)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = readdirSync(changesDir, { withFileTypes: true });
|
||||||
|
return entries
|
||||||
|
.filter((entry) => entry.isDirectory() && entry.name !== 'archive')
|
||||||
|
.map((entry) => entry.name);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format checklist output
|
||||||
|
*
|
||||||
|
* @param cwd Current working directory
|
||||||
|
* @returns Formatted checklist string
|
||||||
|
*/
|
||||||
|
function formatChecklist(cwd: string): string {
|
||||||
|
const git = getGitStatus(cwd);
|
||||||
|
const commits = getRecentCommits(cwd);
|
||||||
|
const changes = getActiveChanges(cwd);
|
||||||
|
|
||||||
|
const lines = ['═'.repeat(70), '📋 SESSION CHECKLIST', '═'.repeat(70), ''];
|
||||||
|
|
||||||
|
// Git status
|
||||||
|
if (git) {
|
||||||
|
lines.push('Git Status:', ` Branch: ${git.branch}`);
|
||||||
|
|
||||||
|
const status = [];
|
||||||
|
if (git.staged > 0) status.push(`${git.staged} staged`);
|
||||||
|
if (git.unstaged > 0) status.push(`${git.unstaged} modified`);
|
||||||
|
if (git.untracked > 0) status.push(`${git.untracked} untracked`);
|
||||||
|
|
||||||
|
if (status.length > 0) {
|
||||||
|
lines.push(` Status: ${status.join(', ')}`);
|
||||||
|
} else {
|
||||||
|
lines.push(' Status: Clean working directory');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent commits
|
||||||
|
if (commits.length > 0) {
|
||||||
|
lines.push('', 'Recent Commits:');
|
||||||
|
for (const commit of commits) {
|
||||||
|
lines.push(` ${commit}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lines.push('Git: Not a git repository');
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
// OpenSpec changes
|
||||||
|
if (changes.length > 0) {
|
||||||
|
lines.push('Active Changes:');
|
||||||
|
for (const change of changes) {
|
||||||
|
lines.push(` • ${change}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lines.push('OpenSpec: No active changes');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick reference
|
||||||
|
lines.push(
|
||||||
|
'',
|
||||||
|
'─'.repeat(70),
|
||||||
|
'Quick Commands:',
|
||||||
|
' openspec list # List active changes',
|
||||||
|
' openspec show <change> # View change details',
|
||||||
|
' git status # Detailed git status',
|
||||||
|
' bun run format # Format code',
|
||||||
|
' bun run lint # Lint code',
|
||||||
|
'═'.repeat(70),
|
||||||
|
);
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main hook execution
|
||||||
|
*/
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const input = await parseStdin();
|
||||||
|
|
||||||
|
// Output checklist
|
||||||
|
const checklist = formatChecklist(input.cwd);
|
||||||
|
console.log(checklist);
|
||||||
|
|
||||||
|
// Performance check
|
||||||
|
checkPerformance(startTime, 100, 'session-checklist');
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(formatError(error, 'session-checklist'));
|
||||||
|
// Don't fail session start - exit cleanly
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
await main();
|
||||||
326
hooks/session-start.ts
Executable file
326
hooks/session-start.ts
Executable file
@@ -0,0 +1,326 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SessionStart Hook for OpenSpec Workflow
|
||||||
|
*
|
||||||
|
* This hook automatically loads context from the active OpenSpec change when
|
||||||
|
* a new Claude Code session starts or resumes. It reads .openspec/active.json
|
||||||
|
* to find the current change, then loads the proposal, design, and tasks to
|
||||||
|
* restore context seamlessly.
|
||||||
|
*
|
||||||
|
* @see {@link https://github.com/jbabin91/super-claude} for documentation
|
||||||
|
*
|
||||||
|
* Runtime: Bun (native TypeScript support)
|
||||||
|
* Execution: Triggered on session start/resume
|
||||||
|
* Performance Target: <100ms
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active change tracker schema
|
||||||
|
*/
|
||||||
|
type ActiveChange = {
|
||||||
|
change: string; // Change ID (folder name)
|
||||||
|
started: string; // ISO 8601 timestamp
|
||||||
|
lastCheckpoint: string; // ISO 8601 timestamp
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook input from Claude Code (via stdin)
|
||||||
|
*/
|
||||||
|
type HookInput = {
|
||||||
|
cwd: string; // Current working directory
|
||||||
|
[key: string]: unknown; // Other potential fields
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse hook input from stdin.
|
||||||
|
*
|
||||||
|
* @returns Parsed HookInput object
|
||||||
|
* @throws Error if stdin is invalid
|
||||||
|
*/
|
||||||
|
async function parseStdin(): Promise<HookInput> {
|
||||||
|
const stdin = await Bun.stdin.text();
|
||||||
|
|
||||||
|
if (!stdin || stdin.trim() === '') {
|
||||||
|
throw new Error('No input received from stdin');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const input = JSON.parse(stdin) as HookInput;
|
||||||
|
|
||||||
|
if (!input.cwd || typeof input.cwd !== 'string') {
|
||||||
|
throw new Error('Invalid input: missing or invalid cwd field');
|
||||||
|
}
|
||||||
|
|
||||||
|
return input;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SyntaxError) {
|
||||||
|
throw new Error('Invalid JSON from stdin: ' + error.message);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load active change from openspec/active.json
|
||||||
|
*
|
||||||
|
* @param cwd Current working directory
|
||||||
|
* @returns ActiveChange object or null if not found
|
||||||
|
*/
|
||||||
|
function loadActiveChange(cwd: string): ActiveChange | null {
|
||||||
|
const activePath = path.resolve(cwd, 'openspec/active.json');
|
||||||
|
|
||||||
|
if (!existsSync(activePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = readFileSync(activePath, 'utf8');
|
||||||
|
const active = JSON.parse(content) as ActiveChange;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!active.change || typeof active.change !== 'string') {
|
||||||
|
console.warn('[WARNING] Invalid active.json: missing change field');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return active;
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
console.warn('[WARNING] Failed to load active.json: ' + msg);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load file content safely with fallback
|
||||||
|
*
|
||||||
|
* @param path File path
|
||||||
|
* @param fallback Fallback message if file not found
|
||||||
|
* @returns File content or fallback
|
||||||
|
*/
|
||||||
|
function loadFileContent(path: string, fallback: string): string {
|
||||||
|
if (!existsSync(path)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return readFileSync(path, 'utf8');
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
console.warn('[WARNING] Failed to read ' + path + ': ' + msg);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count completed and total tasks from tasks.md
|
||||||
|
*
|
||||||
|
* @param tasksContent Content of tasks.md
|
||||||
|
* @returns Object with total and completed counts
|
||||||
|
*/
|
||||||
|
function countTasks(tasksContent: string): {
|
||||||
|
total: number;
|
||||||
|
completed: number;
|
||||||
|
} {
|
||||||
|
const lines = tasksContent.split('\n');
|
||||||
|
let total = 0;
|
||||||
|
let completed = 0;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.trim().startsWith('- [x]') || line.trim().startsWith('- [X]')) {
|
||||||
|
completed++;
|
||||||
|
total++;
|
||||||
|
} else if (line.trim().startsWith('- [ ]')) {
|
||||||
|
total++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { total, completed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format time elapsed since timestamp
|
||||||
|
*
|
||||||
|
* @param timestamp ISO 8601 timestamp
|
||||||
|
* @returns Human-readable time elapsed
|
||||||
|
*/
|
||||||
|
function formatTimeElapsed(timestamp: string): string {
|
||||||
|
const now = new Date();
|
||||||
|
const then = new Date(timestamp);
|
||||||
|
const diffMs = now.getTime() - then.getTime();
|
||||||
|
|
||||||
|
const minutes = Math.floor(diffMs / 1000 / 60);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
|
||||||
|
if (days > 0) return days + (days === 1 ? ' day ago' : ' days ago');
|
||||||
|
if (hours > 0) return hours + (hours === 1 ? ' hour ago' : ' hours ago');
|
||||||
|
if (minutes > 0)
|
||||||
|
return minutes + (minutes === 1 ? ' minute ago' : ' minutes ago');
|
||||||
|
return 'just now';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get last N lines from content
|
||||||
|
*
|
||||||
|
* @param content File content
|
||||||
|
* @param n Number of lines
|
||||||
|
* @returns Last N lines
|
||||||
|
*/
|
||||||
|
function getLastLines(content: string, n: number): string {
|
||||||
|
const lines = content.trim().split('\n');
|
||||||
|
const lastN = lines.slice(-n);
|
||||||
|
return lastN.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format context output for Claude
|
||||||
|
*
|
||||||
|
* @param active Active change data
|
||||||
|
* @param cwd Current working directory
|
||||||
|
* @returns Formatted context string
|
||||||
|
*/
|
||||||
|
function formatContext(active: ActiveChange, cwd: string): string {
|
||||||
|
const changeDir = path.resolve(cwd, 'openspec/changes', active.change);
|
||||||
|
|
||||||
|
// Check if change directory exists
|
||||||
|
if (!existsSync(changeDir)) {
|
||||||
|
return (
|
||||||
|
'⚠️ Active change "' +
|
||||||
|
active.change +
|
||||||
|
'" not found.\n' +
|
||||||
|
'It may have been archived. Use /openspec:work to select a new change.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load context files
|
||||||
|
const proposalPath = path.join(changeDir, 'proposal.md');
|
||||||
|
const designPath = path.join(changeDir, 'design.md');
|
||||||
|
const tasksPath = path.join(changeDir, 'tasks.md');
|
||||||
|
|
||||||
|
const proposal = loadFileContent(
|
||||||
|
proposalPath,
|
||||||
|
'(No proposal.md - create one with /openspec:proposal)',
|
||||||
|
);
|
||||||
|
const design = loadFileContent(
|
||||||
|
designPath,
|
||||||
|
'(No design.md - create one to track your approach)',
|
||||||
|
);
|
||||||
|
const tasks = loadFileContent(tasksPath, '(No tasks.md - no tasks defined)');
|
||||||
|
|
||||||
|
// Count task progress
|
||||||
|
const taskCounts = countTasks(tasks);
|
||||||
|
const percentage =
|
||||||
|
taskCounts.total > 0
|
||||||
|
? Math.round((taskCounts.completed / taskCounts.total) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Format timestamps
|
||||||
|
const startedAgo = formatTimeElapsed(active.started);
|
||||||
|
const checkpointAgo = active.lastCheckpoint
|
||||||
|
? formatTimeElapsed(active.lastCheckpoint)
|
||||||
|
: 'never';
|
||||||
|
|
||||||
|
// Build output
|
||||||
|
const recentContext = getLastLines(design, 15);
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
'='.repeat(70),
|
||||||
|
'📋 RESUMING OPENSPEC WORK',
|
||||||
|
'='.repeat(70),
|
||||||
|
'',
|
||||||
|
'Active Change: ' + active.change,
|
||||||
|
'Started: ' + startedAgo,
|
||||||
|
'Last Checkpoint: ' + checkpointAgo,
|
||||||
|
'',
|
||||||
|
'Progress:',
|
||||||
|
' Tasks: ' +
|
||||||
|
taskCounts.completed +
|
||||||
|
'/' +
|
||||||
|
taskCounts.total +
|
||||||
|
' (' +
|
||||||
|
percentage +
|
||||||
|
'%)',
|
||||||
|
'',
|
||||||
|
'-'.repeat(70),
|
||||||
|
'PROPOSAL (WHY)',
|
||||||
|
'-'.repeat(70),
|
||||||
|
proposal,
|
||||||
|
'',
|
||||||
|
'-'.repeat(70),
|
||||||
|
'DESIGN (HOW - Living Doc)',
|
||||||
|
'-'.repeat(70),
|
||||||
|
design,
|
||||||
|
'',
|
||||||
|
'-'.repeat(70),
|
||||||
|
'RECENT CHECKPOINT NOTES',
|
||||||
|
'-'.repeat(70),
|
||||||
|
recentContext,
|
||||||
|
'',
|
||||||
|
'-'.repeat(70),
|
||||||
|
'TASKS (WHAT)',
|
||||||
|
'-'.repeat(70),
|
||||||
|
tasks,
|
||||||
|
'',
|
||||||
|
'='.repeat(70),
|
||||||
|
'COMMANDS:',
|
||||||
|
' /openspec:status - Check progress',
|
||||||
|
' /openspec:checkpoint - Save progress',
|
||||||
|
' /openspec:work - Switch changes',
|
||||||
|
' /openspec:done - Complete and archive',
|
||||||
|
'='.repeat(70),
|
||||||
|
];
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main hook execution
|
||||||
|
*
|
||||||
|
* Workflow:
|
||||||
|
* 1. Parse stdin
|
||||||
|
* 2. Load active change
|
||||||
|
* 3. If active, load context files
|
||||||
|
* 4. Format and output context
|
||||||
|
* 5. Exit cleanly
|
||||||
|
*/
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Parse input
|
||||||
|
const input = await parseStdin();
|
||||||
|
|
||||||
|
// 2. Load active change
|
||||||
|
const active = loadActiveChange(input.cwd);
|
||||||
|
|
||||||
|
// If no active change, exit silently (no output)
|
||||||
|
if (!active) {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Format and output context
|
||||||
|
const context = formatContext(active, input.cwd);
|
||||||
|
console.log(context);
|
||||||
|
|
||||||
|
// Performance monitoring
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
if (duration > 100) {
|
||||||
|
console.warn('[WARNING] Slow SessionStart hook: ' + duration + 'ms');
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
console.error('[ERROR] SessionStart hook error: ' + msg);
|
||||||
|
// Don't fail the session start - just exit silently
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
await main();
|
||||||
518
hooks/skill-activation-prompt.ts
Executable file
518
hooks/skill-activation-prompt.ts
Executable file
@@ -0,0 +1,518 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skill Auto-Activation Hook for Claude Code
|
||||||
|
*
|
||||||
|
* This UserPromptSubmit hook analyzes user prompts and suggests relevant skills
|
||||||
|
* before Claude responds. It discovers skill-rules.json files across installed
|
||||||
|
* plugins, merges them with project overrides, and matches against keywords
|
||||||
|
* and intent patterns.
|
||||||
|
*
|
||||||
|
* @see {@link https://github.com/jbabin91/super-claude} for documentation
|
||||||
|
*
|
||||||
|
* Runtime: Bun (native TypeScript support)
|
||||||
|
* Execution: Triggered on every user prompt submission
|
||||||
|
* Performance Target: <50ms for typical projects (<10 plugins)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
HookInput,
|
||||||
|
MatchedSkill,
|
||||||
|
PluginSkillRules,
|
||||||
|
Priority,
|
||||||
|
ProjectSkillRules,
|
||||||
|
SkillConfig,
|
||||||
|
} from '../types/skill-rules.d.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Bun runtime is available.
|
||||||
|
* This function is mainly for documentation - if we're executing, Bun is available.
|
||||||
|
*/
|
||||||
|
function checkBunRuntime(): void {
|
||||||
|
// If this script is running, Bun is available (shebang ensures it)
|
||||||
|
// This check is here for clarity and future enhancement
|
||||||
|
if (typeof Bun === 'undefined') {
|
||||||
|
console.error('[WARNING] Bun required for skill activation');
|
||||||
|
console.error('Install: https://bun.sh');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse hook input from stdin.
|
||||||
|
*
|
||||||
|
* Claude Code passes hook context as JSON via stdin.
|
||||||
|
*
|
||||||
|
* @returns Parsed HookInput object
|
||||||
|
* @throws Error if stdin is empty or invalid JSON
|
||||||
|
*/
|
||||||
|
async function parseStdin(): Promise<HookInput> {
|
||||||
|
const stdin = await Bun.stdin.text();
|
||||||
|
|
||||||
|
if (!stdin || stdin.trim() === '') {
|
||||||
|
throw new Error('No input received from stdin');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const input = JSON.parse(stdin) as HookInput;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!input.prompt || typeof input.prompt !== 'string') {
|
||||||
|
throw new Error('Invalid input: missing or invalid prompt field');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!input.cwd || typeof input.cwd !== 'string') {
|
||||||
|
throw new Error('Invalid input: missing or invalid cwd field');
|
||||||
|
}
|
||||||
|
|
||||||
|
return input;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SyntaxError) {
|
||||||
|
throw new Error('Invalid JSON from stdin: ' + error.message);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover all skill-rules.json files from installed plugins.
|
||||||
|
*
|
||||||
|
* Scans ".claude/skills/star/skill-rules.json" for plugin-level rules.
|
||||||
|
* Handles missing files and invalid JSON gracefully.
|
||||||
|
*
|
||||||
|
* @param cwd Current working directory
|
||||||
|
* @returns Array of validated PluginSkillRules
|
||||||
|
*/
|
||||||
|
function discoverPluginRules(cwd: string): PluginSkillRules[] {
|
||||||
|
const skillsDir = path.resolve(cwd, '.claude/skills');
|
||||||
|
const pluginRules: PluginSkillRules[] = [];
|
||||||
|
|
||||||
|
if (!existsSync(skillsDir)) {
|
||||||
|
return pluginRules;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = readdirSync(skillsDir, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
|
||||||
|
const rulesPath = path.join(skillsDir, entry.name, 'skill-rules.json');
|
||||||
|
|
||||||
|
if (!existsSync(rulesPath)) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = readFileSync(rulesPath, 'utf8');
|
||||||
|
const rules = JSON.parse(content) as PluginSkillRules;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!rules.plugin?.name || !rules.plugin?.namespace || !rules.skills) {
|
||||||
|
console.warn(
|
||||||
|
'[WARNING] Invalid skill-rules.json in ' +
|
||||||
|
entry.name +
|
||||||
|
': missing required fields',
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginRules.push(rules);
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
console.warn(
|
||||||
|
'[WARNING] Failed to load skill-rules.json from ' +
|
||||||
|
entry.name +
|
||||||
|
': ' +
|
||||||
|
msg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
console.warn('[WARNING] Failed to read skills directory: ' + msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pluginRules;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load project-level overrides from .claude/skills/skill-rules.json
|
||||||
|
*
|
||||||
|
* @param cwd Current working directory
|
||||||
|
* @returns ProjectSkillRules or null if not found/invalid
|
||||||
|
*/
|
||||||
|
function loadProjectOverrides(cwd: string): ProjectSkillRules | null {
|
||||||
|
const overridesPath = path.resolve(cwd, '.claude/skills/skill-rules.json');
|
||||||
|
|
||||||
|
if (!existsSync(overridesPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = readFileSync(overridesPath, 'utf8');
|
||||||
|
const overrides = JSON.parse(content) as ProjectSkillRules;
|
||||||
|
|
||||||
|
// Validate schema
|
||||||
|
if (!overrides.version || typeof overrides.version !== 'string') {
|
||||||
|
console.warn(
|
||||||
|
'[WARNING] Invalid project overrides: missing version field',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure required fields exist (with defaults)
|
||||||
|
return {
|
||||||
|
version: overrides.version,
|
||||||
|
overrides: overrides.overrides || {},
|
||||||
|
disabled: overrides.disabled || [],
|
||||||
|
global: overrides.global,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
console.warn('[WARNING] Failed to load project overrides: ' + msg);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge plugin rules with project overrides.
|
||||||
|
*
|
||||||
|
* Precedence: Project overrides > Plugin defaults
|
||||||
|
*
|
||||||
|
* Strategy (MVP):
|
||||||
|
* - Shallow merge for skill configs
|
||||||
|
* - Apply disabled list
|
||||||
|
* - Namespace all skill keys as "namespace/skill-name"
|
||||||
|
*
|
||||||
|
* @param pluginRules Array of plugin-level rules
|
||||||
|
* @param projectOverrides Project-level overrides (optional)
|
||||||
|
* @returns Map of skill ID to merged SkillConfig
|
||||||
|
*/
|
||||||
|
function mergeRules(
|
||||||
|
pluginRules: PluginSkillRules[],
|
||||||
|
projectOverrides: ProjectSkillRules | null,
|
||||||
|
): Map<string, SkillConfig> {
|
||||||
|
const merged = new Map<string, SkillConfig>();
|
||||||
|
|
||||||
|
// 1. Load all plugin rules with namespaced keys
|
||||||
|
for (const plugin of pluginRules) {
|
||||||
|
const namespace = plugin.plugin.namespace;
|
||||||
|
|
||||||
|
for (const [skillName, config] of Object.entries(plugin.skills)) {
|
||||||
|
const skillId = namespace + '/' + skillName;
|
||||||
|
merged.set(skillId, config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Apply project overrides (shallow merge)
|
||||||
|
if (projectOverrides) {
|
||||||
|
for (const [skillId, override] of Object.entries(
|
||||||
|
projectOverrides.overrides,
|
||||||
|
)) {
|
||||||
|
if (!merged.has(skillId)) {
|
||||||
|
console.warn(
|
||||||
|
'[WARNING] Override for unknown skill: ' +
|
||||||
|
skillId +
|
||||||
|
' (skill not found in plugins)',
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shallow merge: spread operator replaces entire nested objects
|
||||||
|
const baseConfig = merged.get(skillId)!;
|
||||||
|
merged.set(skillId, { ...baseConfig, ...override });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Remove disabled skills
|
||||||
|
for (const skillId of projectOverrides.disabled) {
|
||||||
|
merged.delete(skillId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match prompt against skill keywords (case-insensitive literal).
|
||||||
|
*
|
||||||
|
* @param prompt User's prompt text
|
||||||
|
* @param keywords Array of keywords to match
|
||||||
|
* @returns Matched keyword or null
|
||||||
|
*/
|
||||||
|
function matchKeywords(
|
||||||
|
prompt: string,
|
||||||
|
keywords: string[] | undefined,
|
||||||
|
): string | null {
|
||||||
|
if (!keywords || keywords.length === 0) return null;
|
||||||
|
|
||||||
|
const normalizedPrompt = prompt.toLowerCase();
|
||||||
|
|
||||||
|
for (const keyword of keywords) {
|
||||||
|
if (normalizedPrompt.includes(keyword.toLowerCase())) {
|
||||||
|
return keyword;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match prompt against intent patterns (regex with case-insensitive flag).
|
||||||
|
*
|
||||||
|
* @param prompt User's prompt text
|
||||||
|
* @param patterns Array of regex patterns
|
||||||
|
* @returns Matched pattern or null
|
||||||
|
*/
|
||||||
|
function matchIntentPatterns(
|
||||||
|
prompt: string,
|
||||||
|
patterns: string[] | undefined,
|
||||||
|
): string | null {
|
||||||
|
if (!patterns || patterns.length === 0) return null;
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
try {
|
||||||
|
const regex = new RegExp(pattern, 'i');
|
||||||
|
if (regex.test(prompt)) {
|
||||||
|
return pattern;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
console.warn(
|
||||||
|
'[WARNING] Invalid regex pattern: ' + pattern + ' (' + msg + ')',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all skills that match the user's prompt.
|
||||||
|
*
|
||||||
|
* Uses both keyword and intent pattern matching.
|
||||||
|
* Filters by priority threshold and disabled list.
|
||||||
|
*
|
||||||
|
* @param prompt User's prompt text
|
||||||
|
* @param skills Map of skill ID to config
|
||||||
|
* @param globalConfig Global configuration (optional)
|
||||||
|
* @returns Array of matched skills
|
||||||
|
*/
|
||||||
|
function matchSkills(
|
||||||
|
prompt: string,
|
||||||
|
skills: Map<string, SkillConfig>,
|
||||||
|
globalConfig: ProjectSkillRules['global'],
|
||||||
|
): MatchedSkill[] {
|
||||||
|
const matches: MatchedSkill[] = [];
|
||||||
|
|
||||||
|
for (const [skillId, config] of skills.entries()) {
|
||||||
|
const [namespace, name] = skillId.split('/');
|
||||||
|
|
||||||
|
// Try keyword matching
|
||||||
|
const keywordMatch = matchKeywords(prompt, config.promptTriggers.keywords);
|
||||||
|
if (keywordMatch) {
|
||||||
|
matches.push({
|
||||||
|
id: skillId,
|
||||||
|
name,
|
||||||
|
namespace,
|
||||||
|
config,
|
||||||
|
matchType: 'keyword',
|
||||||
|
matchedBy: keywordMatch,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try intent pattern matching
|
||||||
|
const patternMatch = matchIntentPatterns(
|
||||||
|
prompt,
|
||||||
|
config.promptTriggers.intentPatterns,
|
||||||
|
);
|
||||||
|
if (patternMatch) {
|
||||||
|
matches.push({
|
||||||
|
id: skillId,
|
||||||
|
name,
|
||||||
|
namespace,
|
||||||
|
config,
|
||||||
|
matchType: 'intent',
|
||||||
|
matchedBy: patternMatch,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply priority threshold filter
|
||||||
|
let filtered = matches;
|
||||||
|
if (globalConfig?.priorityThreshold) {
|
||||||
|
const priorityOrder: Record<Priority, number> = {
|
||||||
|
critical: 0,
|
||||||
|
high: 1,
|
||||||
|
medium: 2,
|
||||||
|
low: 3,
|
||||||
|
};
|
||||||
|
const threshold = priorityOrder[globalConfig.priorityThreshold];
|
||||||
|
|
||||||
|
filtered = matches.filter(
|
||||||
|
(match) => priorityOrder[match.config.priority] <= threshold,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by priority (critical > high > medium > low)
|
||||||
|
const priorityOrder: Record<Priority, number> = {
|
||||||
|
critical: 0,
|
||||||
|
high: 1,
|
||||||
|
medium: 2,
|
||||||
|
low: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
filtered.sort(
|
||||||
|
(a, b) =>
|
||||||
|
priorityOrder[a.config.priority] - priorityOrder[b.config.priority],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Apply maxSkillsPerPrompt limit
|
||||||
|
if (globalConfig?.maxSkillsPerPrompt) {
|
||||||
|
filtered = filtered.slice(0, globalConfig.maxSkillsPerPrompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format matched skills for output.
|
||||||
|
*
|
||||||
|
* Groups skills by priority and formats with box drawing and emojis.
|
||||||
|
*
|
||||||
|
* @param matches Array of matched skills
|
||||||
|
* @returns Formatted string for stdout
|
||||||
|
*/
|
||||||
|
function formatOutput(matches: MatchedSkill[]): string {
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return ''; // No output when no matches
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by priority
|
||||||
|
const byPriority: Record<Priority, MatchedSkill[]> = {
|
||||||
|
critical: [],
|
||||||
|
high: [],
|
||||||
|
medium: [],
|
||||||
|
low: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const match of matches) {
|
||||||
|
byPriority[match.config.priority].push(match);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build sections conditionally
|
||||||
|
const criticalSection =
|
||||||
|
byPriority.critical.length > 0
|
||||||
|
? [
|
||||||
|
'[CRITICAL] REQUIRED SKILLS:',
|
||||||
|
...byPriority.critical.map((match) => ' -> ' + match.name),
|
||||||
|
'',
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const highSection =
|
||||||
|
byPriority.high.length > 0
|
||||||
|
? [
|
||||||
|
'[RECOMMENDED] SKILLS:',
|
||||||
|
...byPriority.high.map((match) => ' -> ' + match.name),
|
||||||
|
'',
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const mediumSection =
|
||||||
|
byPriority.medium.length > 0
|
||||||
|
? [
|
||||||
|
'[OPTIONAL] SKILLS:',
|
||||||
|
...byPriority.medium.map((match) => ' -> ' + match.name),
|
||||||
|
'',
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const lowSection =
|
||||||
|
byPriority.low.length > 0
|
||||||
|
? [
|
||||||
|
'[SUGGESTED] SKILLS:',
|
||||||
|
...byPriority.low.map((match) => ' -> ' + match.name),
|
||||||
|
'',
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
'='.repeat(60),
|
||||||
|
'SKILL ACTIVATION CHECK',
|
||||||
|
'='.repeat(60),
|
||||||
|
'',
|
||||||
|
...criticalSection,
|
||||||
|
...highSection,
|
||||||
|
...mediumSection,
|
||||||
|
...lowSection,
|
||||||
|
'ACTION: Use Skill tool BEFORE responding',
|
||||||
|
'='.repeat(60),
|
||||||
|
];
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main hook execution.
|
||||||
|
*
|
||||||
|
* Workflow:
|
||||||
|
* 1. Check Bun runtime
|
||||||
|
* 2. Parse stdin
|
||||||
|
* 3. Discover plugin rules
|
||||||
|
* 4. Load project overrides
|
||||||
|
* 5. Merge rules
|
||||||
|
* 6. Match prompt
|
||||||
|
* 7. Format output
|
||||||
|
* 8. Exit cleanly
|
||||||
|
*/
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Check runtime
|
||||||
|
checkBunRuntime();
|
||||||
|
|
||||||
|
// 2. Parse input
|
||||||
|
const input = await parseStdin();
|
||||||
|
|
||||||
|
// 3. Discover plugin rules
|
||||||
|
const pluginRules = discoverPluginRules(input.cwd);
|
||||||
|
|
||||||
|
// 4. Load project overrides
|
||||||
|
const projectOverrides = loadProjectOverrides(input.cwd);
|
||||||
|
|
||||||
|
// 5. Merge rules
|
||||||
|
const mergedSkills = mergeRules(pluginRules, projectOverrides);
|
||||||
|
|
||||||
|
// 6. Match skills
|
||||||
|
const matches = matchSkills(
|
||||||
|
input.prompt,
|
||||||
|
mergedSkills,
|
||||||
|
projectOverrides?.global,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 7. Format and output
|
||||||
|
const output = formatOutput(matches);
|
||||||
|
if (output) {
|
||||||
|
console.log(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Performance monitoring
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
if (duration > 50) {
|
||||||
|
console.warn('[WARNING] Slow hook execution: ' + duration + 'ms');
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
console.error('[ERROR] Hook error: ' + msg);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
await main();
|
||||||
324
hooks/type-checker.ts
Executable file
324
hooks/type-checker.ts
Executable file
@@ -0,0 +1,324 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type Checker Hook
|
||||||
|
*
|
||||||
|
* Pre-validates TypeScript types before Edit/Write operations.
|
||||||
|
* Uses @jbabin91/tsc-files programmatic API for incremental type checking with tsgo support.
|
||||||
|
* Informs about type errors but allows file modifications (informative only).
|
||||||
|
*
|
||||||
|
* Smart behavior:
|
||||||
|
* - Skips non-TypeScript files (no performance impact)
|
||||||
|
* - Skips projects without tsconfig.json
|
||||||
|
* - Dynamically imports type checker only when needed
|
||||||
|
*
|
||||||
|
* Performance: ~100-200ms with tsgo (10x faster than tsc)
|
||||||
|
* Performance target: <2s (ADR-0010) - typically well under this
|
||||||
|
* Hook behavior: Informative (does not block execution)
|
||||||
|
*
|
||||||
|
* @see {@link https://github.com/jbabin91/super-claude} for documentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import type { CheckResult } from '@jbabin91/tsc-files';
|
||||||
|
|
||||||
|
import {
|
||||||
|
checkHookEnabled,
|
||||||
|
checkPerformance,
|
||||||
|
ensureBunInstalled,
|
||||||
|
formatError,
|
||||||
|
parseStdin,
|
||||||
|
} from './utils/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if file is TypeScript
|
||||||
|
*
|
||||||
|
* @param filePath File path to check
|
||||||
|
* @returns true if TypeScript file
|
||||||
|
*/
|
||||||
|
function isTypeScriptFile(filePath: string): boolean {
|
||||||
|
return /\.(ts|tsx)$/.test(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if tsconfig.json exists
|
||||||
|
*
|
||||||
|
* @param cwd Current working directory
|
||||||
|
* @returns true if tsconfig.json found
|
||||||
|
*/
|
||||||
|
function hasTsConfig(cwd: string): boolean {
|
||||||
|
return (
|
||||||
|
existsSync(path.join(cwd, 'tsconfig.json')) ||
|
||||||
|
existsSync(path.join(cwd, 'tsconfig.base.json'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run TypeScript type checking on file using programmatic API
|
||||||
|
*
|
||||||
|
* @param cwd Current working directory
|
||||||
|
* @param filePath File path to check
|
||||||
|
* @returns CheckResult with structured error data
|
||||||
|
*/
|
||||||
|
async function checkTypes(cwd: string, filePath: string): Promise<CheckResult> {
|
||||||
|
try {
|
||||||
|
// Dynamically import checkFiles only when needed
|
||||||
|
// This prevents loading TypeScript dependencies for non-TS files
|
||||||
|
const module = await import('@jbabin91/tsc-files');
|
||||||
|
|
||||||
|
// Defensive check: ensure checkFiles exists after import
|
||||||
|
if (!module.checkFiles || typeof module.checkFiles !== 'function') {
|
||||||
|
throw new Error(
|
||||||
|
'Type checker module loaded but checkFiles function not found. ' +
|
||||||
|
'This may indicate a version mismatch. ' +
|
||||||
|
'Try: bun install @jbabin91/tsc-files@latest',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { checkFiles } = module;
|
||||||
|
|
||||||
|
// Use programmatic API for structured error data
|
||||||
|
// Automatically uses tsgo if available (10x faster)
|
||||||
|
const result = await checkFiles([filePath], {
|
||||||
|
cwd,
|
||||||
|
skipLibCheck: true,
|
||||||
|
verbose: false,
|
||||||
|
throwOnError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
// Handle different error scenarios with helpful messages
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
|
// Check if this is a module not found error
|
||||||
|
const isModuleNotFound =
|
||||||
|
errorMessage.includes('Cannot find package') ||
|
||||||
|
errorMessage.includes('Cannot find module') ||
|
||||||
|
errorMessage.includes('@jbabin91/tsc-files');
|
||||||
|
|
||||||
|
let helpfulMessage = errorMessage;
|
||||||
|
if (isModuleNotFound) {
|
||||||
|
helpfulMessage =
|
||||||
|
'Type checker dependency not installed.\n\n' +
|
||||||
|
'To enable type checking, install the required package:\n' +
|
||||||
|
' bun install @jbabin91/tsc-files\n\n' +
|
||||||
|
'Or disable this hook in .claude/super-claude-config.json:\n' +
|
||||||
|
' "workflow": { "hooks": { "typeChecker": { "enabled": false } } }\n\n' +
|
||||||
|
`Original error: ${errorMessage}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a failed result with helpful error message
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errorCount: 1,
|
||||||
|
warningCount: 0,
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
file: filePath,
|
||||||
|
line: 0,
|
||||||
|
column: 0,
|
||||||
|
message: helpfulMessage,
|
||||||
|
code: isModuleNotFound ? 'HOOK_ERROR' : 'TS0000',
|
||||||
|
severity: 'error',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
warnings: [],
|
||||||
|
checkedFiles: [filePath],
|
||||||
|
duration: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format type errors for display with categorization
|
||||||
|
*
|
||||||
|
* @param result CheckResult from tsc-files API
|
||||||
|
* @param targetFile The file being edited
|
||||||
|
* @returns Formatted error message
|
||||||
|
*/
|
||||||
|
function formatTypeErrors(result: CheckResult, targetFile: string): string {
|
||||||
|
// Check if this is a hook error (not a type error)
|
||||||
|
const hasHookError = result.errors.some((e) => e.code === 'HOOK_ERROR');
|
||||||
|
|
||||||
|
if (hasHookError) {
|
||||||
|
// Format hook configuration errors differently
|
||||||
|
const hookError = result.errors.find((e) => e.code === 'HOOK_ERROR');
|
||||||
|
return [
|
||||||
|
'',
|
||||||
|
'═'.repeat(70),
|
||||||
|
'⚠️ TYPE CHECKER HOOK ERROR',
|
||||||
|
'═'.repeat(70),
|
||||||
|
'',
|
||||||
|
hookError?.message ?? 'Unknown hook error',
|
||||||
|
'',
|
||||||
|
'═'.repeat(70),
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Categorize errors by file
|
||||||
|
const targetFileErrors = result.errors.filter((e) => e.file === targetFile);
|
||||||
|
const dependencyErrors = result.errors.filter((e) => e.file !== targetFile);
|
||||||
|
|
||||||
|
// Header
|
||||||
|
const sections: string[] = [
|
||||||
|
'',
|
||||||
|
'═'.repeat(70),
|
||||||
|
'⚠️ TYPE ERRORS DETECTED - ACTION REQUIRED',
|
||||||
|
'═'.repeat(70),
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Target file errors (critical)
|
||||||
|
if (targetFileErrors.length > 0) {
|
||||||
|
sections.push(
|
||||||
|
'🎯 ERRORS IN THIS FILE:',
|
||||||
|
` File: ${targetFile}`,
|
||||||
|
' Action: Fix these before proceeding to next task',
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const err of targetFileErrors.slice(0, 10)) {
|
||||||
|
sections.push(
|
||||||
|
` ${err.file}:${err.line}:${err.column}`,
|
||||||
|
` ${err.code}: ${err.message}`,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetFileErrors.length > 10) {
|
||||||
|
sections.push(
|
||||||
|
` ... and ${targetFileErrors.length - 10} more errors in this file`,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dependency errors (informational)
|
||||||
|
if (dependencyErrors.length > 0) {
|
||||||
|
sections.push(
|
||||||
|
'─'.repeat(70),
|
||||||
|
'ℹ️ ERRORS IN DEPENDENCIES:',
|
||||||
|
' These errors are in imported files',
|
||||||
|
' Fix them separately or add to your todo list',
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group by file
|
||||||
|
const byFile = new Map<string, CheckResult['errors'][number][]>();
|
||||||
|
for (const err of dependencyErrors) {
|
||||||
|
if (!byFile.has(err.file)) {
|
||||||
|
byFile.set(err.file, []);
|
||||||
|
}
|
||||||
|
byFile.get(err.file)!.push(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
let fileCount = 0;
|
||||||
|
for (const [file, errors] of byFile.entries()) {
|
||||||
|
if (fileCount >= 5) break;
|
||||||
|
sections.push(` 📄 ${file} (${errors.length} errors)`);
|
||||||
|
fileCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (byFile.size > 5) {
|
||||||
|
sections.push(` ... and ${byFile.size - 5} more files`);
|
||||||
|
}
|
||||||
|
sections.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer with workflow guidance
|
||||||
|
sections.push(
|
||||||
|
'─'.repeat(70),
|
||||||
|
'🤖 CLAUDE: Type errors detected.',
|
||||||
|
'',
|
||||||
|
'Recommended workflow:',
|
||||||
|
' 1. If working on a task: Add "Fix type errors" to your todo list',
|
||||||
|
' 2. Complete your current task first',
|
||||||
|
' 3. Then fix these type errors before moving to next task',
|
||||||
|
'',
|
||||||
|
'If the type error is directly related to your current edit:',
|
||||||
|
' → Fix it immediately as part of this change',
|
||||||
|
'',
|
||||||
|
'User: To disable this hook, add to .claude/settings.json:',
|
||||||
|
' { "customHooks": { "typeChecker": { "enabled": false } } }',
|
||||||
|
'═'.repeat(70),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
|
||||||
|
return sections.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main hook execution
|
||||||
|
*/
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Ensure Bun is installed (fail fast with helpful message)
|
||||||
|
ensureBunInstalled();
|
||||||
|
|
||||||
|
const input = await parseStdin();
|
||||||
|
|
||||||
|
// Check if hook is enabled
|
||||||
|
checkHookEnabled(input.cwd, 'typeChecker');
|
||||||
|
|
||||||
|
// Only run for Edit and Write tools
|
||||||
|
if (input.tool_name !== 'Edit' && input.tool_name !== 'Write') {
|
||||||
|
process.exit(0); // Not a file modification tool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract file path from tool input
|
||||||
|
const toolInput = input.tool_input!;
|
||||||
|
const filePath = toolInput?.file_path as string | undefined;
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
process.exit(0); // No file path
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if not TypeScript file
|
||||||
|
if (!isTypeScriptFile(filePath)) {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if no tsconfig
|
||||||
|
if (!hasTsConfig(input.cwd)) {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check types using programmatic API
|
||||||
|
const result = await checkTypes(input.cwd, filePath);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
// Type errors found - inform but allow operation
|
||||||
|
const errorMessage = formatTypeErrors(result, filePath);
|
||||||
|
|
||||||
|
// Output hookSpecificOutput to inform (not block)
|
||||||
|
const output = {
|
||||||
|
hookSpecificOutput: {
|
||||||
|
hookEventName: 'PreToolUse',
|
||||||
|
permissionDecision: 'allow', // Informative only
|
||||||
|
permissionDecisionReason: errorMessage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(JSON.stringify(output));
|
||||||
|
checkPerformance(startTime, 2000, 'type-checker');
|
||||||
|
process.exit(0); // Exit 0 after informing
|
||||||
|
}
|
||||||
|
|
||||||
|
// Types are valid - allow operation silently
|
||||||
|
checkPerformance(startTime, 2000, 'type-checker');
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(formatError(error, 'type-checker'));
|
||||||
|
// On hook error, don't block the operation
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
await main();
|
||||||
190
hooks/utils/config-loader.ts
Normal file
190
hooks/utils/config-loader.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* Hook Configuration Loader
|
||||||
|
*
|
||||||
|
* Loads hook configuration from multiple sources with priority:
|
||||||
|
* 1. Environment variables (CLAUDE_HOOK_{NAME}_ENABLED) - session override
|
||||||
|
* 2. .claude/settings.local.json (gitignored, personal overrides)
|
||||||
|
* 3. .claude/settings.json (project, committed)
|
||||||
|
* 4. .claude/super-claude-config.json (unified plugin config)
|
||||||
|
* 5. ~/.claude/settings.json (global user defaults)
|
||||||
|
* 6. Plugin defaults (enabled: true)
|
||||||
|
*
|
||||||
|
* Supports both native Claude Code settings (customHooks) and unified plugin config (workflow.hooks).
|
||||||
|
*
|
||||||
|
* @see {@link https://docs.claude.com/en/docs/claude-code/settings} for settings hierarchy
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook configuration schema
|
||||||
|
*/
|
||||||
|
export type HookConfig = {
|
||||||
|
enabled: boolean;
|
||||||
|
[key: string]: unknown; // Allow hook-specific config
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings file schema (partial - only customHooks)
|
||||||
|
*/
|
||||||
|
type SettingsFile = {
|
||||||
|
customHooks?: Record<string, HookConfig>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified plugin config schema (partial - only workflow.hooks)
|
||||||
|
*/
|
||||||
|
type UnifiedConfig = {
|
||||||
|
workflow?: {
|
||||||
|
hooks?: Record<string, HookConfig>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load settings file safely
|
||||||
|
*
|
||||||
|
* @param filePath Path to settings.json file
|
||||||
|
* @returns Parsed settings or null if not found/invalid
|
||||||
|
*/
|
||||||
|
function loadSettingsFile(filePath: string): SettingsFile | null {
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = readFileSync(filePath, 'utf8');
|
||||||
|
return JSON.parse(content) as SettingsFile;
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
console.warn(`[WARNING] Failed to load ${filePath}: ${msg}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load unified plugin config file safely
|
||||||
|
*
|
||||||
|
* @param filePath Path to super-claude-config.json file
|
||||||
|
* @returns Parsed config or null if not found/invalid
|
||||||
|
*/
|
||||||
|
function loadUnifiedConfig(filePath: string): UnifiedConfig | null {
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = readFileSync(filePath, 'utf8');
|
||||||
|
return JSON.parse(content) as UnifiedConfig;
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
console.warn(`[WARNING] Failed to load ${filePath}: ${msg}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load hook configuration from settings hierarchy
|
||||||
|
*
|
||||||
|
* Configuration priority (highest to lowest):
|
||||||
|
* 1. Environment variables (CLAUDE_HOOK_{HOOK_NAME}_ENABLED)
|
||||||
|
* 2. Local overrides (.claude/settings.local.json)
|
||||||
|
* 3. Project settings (.claude/settings.json)
|
||||||
|
* 4. Unified plugin config (.claude/super-claude-config.json)
|
||||||
|
* 5. Global settings (~/.claude/settings.json)
|
||||||
|
* 6. Default (enabled: true)
|
||||||
|
*
|
||||||
|
* @param cwd Current working directory
|
||||||
|
* @param hookName Hook name (e.g., 'gitCommitGuard')
|
||||||
|
* @returns Hook configuration
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const config = loadHookConfig(cwd, 'gitCommitGuard');
|
||||||
|
* if (!config.enabled) {
|
||||||
|
* console.log('Hook disabled by config');
|
||||||
|
* process.exit(0);
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function loadHookConfig(cwd: string, hookName: string): HookConfig {
|
||||||
|
// Load all config files
|
||||||
|
const localPath = path.join(cwd, '.claude', 'settings.local.json');
|
||||||
|
const projectPath = path.join(cwd, '.claude', 'settings.json');
|
||||||
|
const unifiedPath = path.join(cwd, '.claude', 'super-claude-config.json');
|
||||||
|
const globalPath = path.join(
|
||||||
|
process.env.HOME ?? process.env.USERPROFILE ?? '~',
|
||||||
|
'.claude',
|
||||||
|
'settings.json',
|
||||||
|
);
|
||||||
|
|
||||||
|
const local = loadSettingsFile(localPath);
|
||||||
|
const project = loadSettingsFile(projectPath);
|
||||||
|
const unified = loadUnifiedConfig(unifiedPath);
|
||||||
|
const global = loadSettingsFile(globalPath);
|
||||||
|
|
||||||
|
// Check environment variable override
|
||||||
|
const envKey = `CLAUDE_HOOK_${hookName.toUpperCase()}_ENABLED`;
|
||||||
|
const envEnabled = process.env[envKey];
|
||||||
|
|
||||||
|
// Merge config in reverse priority order (lowest to highest)
|
||||||
|
const config: HookConfig = { enabled: true };
|
||||||
|
|
||||||
|
// 1. Start with global settings
|
||||||
|
if (global?.customHooks?.[hookName]) {
|
||||||
|
Object.assign(config, global.customHooks[hookName]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Override with unified plugin config
|
||||||
|
if (unified?.workflow?.hooks?.[hookName]) {
|
||||||
|
Object.assign(config, unified.workflow.hooks[hookName]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Override with project settings
|
||||||
|
if (project?.customHooks?.[hookName]) {
|
||||||
|
Object.assign(config, project.customHooks[hookName]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Override with local settings
|
||||||
|
if (local?.customHooks?.[hookName]) {
|
||||||
|
Object.assign(config, local.customHooks[hookName]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Override with environment variable (highest priority)
|
||||||
|
if (envEnabled !== undefined) {
|
||||||
|
config.enabled = envEnabled === 'true' || envEnabled === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if hook is enabled
|
||||||
|
*
|
||||||
|
* Convenience function that exits cleanly if hook is disabled.
|
||||||
|
* Call this at the start of your hook to respect user configuration.
|
||||||
|
*
|
||||||
|
* @param cwd Current working directory
|
||||||
|
* @param hookName Hook name
|
||||||
|
* @returns true if enabled, never returns if disabled (exits process)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const input = await parseStdin();
|
||||||
|
* checkHookEnabled(input.cwd, 'gitCommitGuard'); // Exits if disabled
|
||||||
|
* // Continue with hook logic...
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function checkHookEnabled(cwd: string, hookName: string): boolean {
|
||||||
|
const config = loadHookConfig(cwd, hookName);
|
||||||
|
|
||||||
|
if (!config.enabled) {
|
||||||
|
// Exit cleanly without output (hook disabled)
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
117
hooks/utils/config-types.ts
Normal file
117
hooks/utils/config-types.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* Type definitions for super-claude-config.json
|
||||||
|
*
|
||||||
|
* Supports both plugin-level defaults and project-level overrides
|
||||||
|
* with deep merge behavior.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skill auto-activation triggers
|
||||||
|
*/
|
||||||
|
export type SkillTriggers = {
|
||||||
|
/** Literal keywords for case-insensitive matching */
|
||||||
|
keywords?: string[];
|
||||||
|
/** Regex patterns for intent matching */
|
||||||
|
patterns?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete skill configuration (plugin defaults)
|
||||||
|
*/
|
||||||
|
export type SkillConfig = {
|
||||||
|
/** Whether this skill is enabled */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** Auto-activation triggers */
|
||||||
|
triggers?: SkillTriggers;
|
||||||
|
/** Additional skill-specific settings */
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Partial skill configuration (project overrides)
|
||||||
|
*/
|
||||||
|
export type SkillOverride = Partial<SkillConfig>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook configuration with plugin-specific settings
|
||||||
|
*/
|
||||||
|
export type HookConfig = {
|
||||||
|
/** Whether this hook is enabled */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** Additional hook-specific settings */
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Partial hook configuration (project overrides)
|
||||||
|
*/
|
||||||
|
export type HookOverride = Partial<HookConfig>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plugin-level configuration (plugins/{plugin}/super-claude-config.json)
|
||||||
|
*/
|
||||||
|
export type PluginConfig = {
|
||||||
|
/** Plugin identifier matching directory name */
|
||||||
|
plugin: string;
|
||||||
|
/** Skill configurations keyed by skill name */
|
||||||
|
skills?: Record<string, SkillConfig>;
|
||||||
|
/** Hook configurations keyed by hook name */
|
||||||
|
hooks?: Record<string, HookConfig>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project-level configuration (.claude/super-claude-config.json)
|
||||||
|
*
|
||||||
|
* Structure: { [pluginName]: { skills: {...}, hooks: {...} } }
|
||||||
|
*/
|
||||||
|
export type ProjectConfig = Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
skills?: Record<string, SkillOverride>;
|
||||||
|
hooks?: Record<string, HookOverride>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolved configuration after merging defaults and overrides
|
||||||
|
*/
|
||||||
|
export type ResolvedConfig = {
|
||||||
|
skills: Record<string, SkillConfig>;
|
||||||
|
hooks: Record<string, HookConfig>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration loading options
|
||||||
|
*/
|
||||||
|
export type ConfigLoaderOptions = {
|
||||||
|
/** Current working directory */
|
||||||
|
cwd: string;
|
||||||
|
/** Plugin name to load config for */
|
||||||
|
pluginName: string;
|
||||||
|
/** Whether to cache loaded configuration */
|
||||||
|
cache?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy skill-rules.json format for backwards compatibility
|
||||||
|
*/
|
||||||
|
export type LegacySkillRules = {
|
||||||
|
plugin: {
|
||||||
|
namespace: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
skills: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
priority?: 'critical' | 'high' | 'medium' | 'low';
|
||||||
|
promptTriggers?: {
|
||||||
|
keywords?: string[];
|
||||||
|
intentPatterns?: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
overrides?: {
|
||||||
|
disabled?: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
181
hooks/utils/config-validation.ts
Normal file
181
hooks/utils/config-validation.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
/**
|
||||||
|
* Runtime validation schemas for super-claude-config.json
|
||||||
|
*
|
||||||
|
* Uses ArkType for TypeScript-native validation with excellent error messages.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type } from 'arktype';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
HookConfig,
|
||||||
|
LegacySkillRules,
|
||||||
|
PluginConfig,
|
||||||
|
ProjectConfig,
|
||||||
|
SkillConfig,
|
||||||
|
} from './config-types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skill configuration validation schema
|
||||||
|
*/
|
||||||
|
export const skillConfigSchema = type({
|
||||||
|
'enabled?': 'boolean',
|
||||||
|
'triggers?': 'object',
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook configuration validation schema
|
||||||
|
*
|
||||||
|
* Allows additional properties for hook-specific settings
|
||||||
|
*/
|
||||||
|
export const hookConfigSchema = type({
|
||||||
|
'enabled?': 'boolean',
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plugin-level configuration validation schema
|
||||||
|
*/
|
||||||
|
export const pluginConfigSchema = type({
|
||||||
|
plugin: 'string',
|
||||||
|
'skills?': 'object',
|
||||||
|
'hooks?': 'object',
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy skill-rules.json validation schema
|
||||||
|
*/
|
||||||
|
export const legacySkillRulesSchema = type({
|
||||||
|
plugin: {
|
||||||
|
namespace: 'string',
|
||||||
|
name: 'string',
|
||||||
|
},
|
||||||
|
skills: 'object',
|
||||||
|
'overrides?': {
|
||||||
|
'disabled?': 'string[]',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation result type
|
||||||
|
*/
|
||||||
|
export type ValidationResult<T> =
|
||||||
|
| { success: true; data: T }
|
||||||
|
| { success: false; errors: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate plugin configuration
|
||||||
|
*
|
||||||
|
* @param data Configuration data to validate
|
||||||
|
* @returns Validation result with typed data or errors
|
||||||
|
*/
|
||||||
|
export function validatePluginConfig(
|
||||||
|
data: unknown,
|
||||||
|
): ValidationResult<PluginConfig> {
|
||||||
|
const result = pluginConfigSchema(data);
|
||||||
|
|
||||||
|
if (result instanceof type.errors) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: result.summary,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: result as PluginConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate project configuration
|
||||||
|
*
|
||||||
|
* @param data Configuration data to validate
|
||||||
|
* @returns Validation result with typed data or errors
|
||||||
|
*/
|
||||||
|
export function validateProjectConfig(
|
||||||
|
data: unknown,
|
||||||
|
): ValidationResult<ProjectConfig> {
|
||||||
|
// Basic object check
|
||||||
|
if (typeof data !== 'object' || data === null) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: 'Project configuration must be an object',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: data as ProjectConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate skill configuration
|
||||||
|
*
|
||||||
|
* @param data Skill config data to validate
|
||||||
|
* @returns Validation result with typed data or errors
|
||||||
|
*/
|
||||||
|
export function validateSkillConfig(
|
||||||
|
data: unknown,
|
||||||
|
): ValidationResult<SkillConfig> {
|
||||||
|
const result = skillConfigSchema(data);
|
||||||
|
|
||||||
|
if (result instanceof type.errors) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: result.summary,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: result as SkillConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate hook configuration
|
||||||
|
*
|
||||||
|
* @param data Hook config data to validate
|
||||||
|
* @returns Validation result with typed data or errors
|
||||||
|
*/
|
||||||
|
export function validateHookConfig(
|
||||||
|
data: unknown,
|
||||||
|
): ValidationResult<HookConfig> {
|
||||||
|
const result = hookConfigSchema(data);
|
||||||
|
|
||||||
|
if (result instanceof type.errors) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: result.summary,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: result as HookConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate legacy skill-rules.json format
|
||||||
|
*
|
||||||
|
* @param data Legacy config data to validate
|
||||||
|
* @returns Validation result with typed data or errors
|
||||||
|
*/
|
||||||
|
export function validateLegacySkillRules(
|
||||||
|
data: unknown,
|
||||||
|
): ValidationResult<LegacySkillRules> {
|
||||||
|
const result = legacySkillRulesSchema(data);
|
||||||
|
|
||||||
|
if (result instanceof type.errors) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: result.summary,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: result as LegacySkillRules,
|
||||||
|
};
|
||||||
|
}
|
||||||
94
hooks/utils/hook-input.ts
Normal file
94
hooks/utils/hook-input.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Hook Input Parsing Utilities
|
||||||
|
*
|
||||||
|
* Shared utilities for parsing Claude Code hook input from stdin.
|
||||||
|
* All hooks receive JSON via stdin with fields like cwd, tool_name, tool_input, etc.
|
||||||
|
*
|
||||||
|
* @see {@link https://docs.claude.com/en/docs/claude-code/hooks} for hook input spec
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard hook input schema from Claude Code
|
||||||
|
*/
|
||||||
|
export type HookInput = {
|
||||||
|
cwd: string; // Current working directory
|
||||||
|
tool_name?: string; // Tool being invoked (PreToolUse/PostToolUse)
|
||||||
|
tool_input?: Record<string, unknown>; // Tool parameters
|
||||||
|
transcript_path?: string; // Path to conversation transcript JSON
|
||||||
|
[key: string]: unknown; // Allow additional fields
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse hook input from stdin.
|
||||||
|
*
|
||||||
|
* Reads JSON from stdin and validates required fields.
|
||||||
|
* Throws error for invalid input to fail fast.
|
||||||
|
*
|
||||||
|
* @returns Parsed HookInput object
|
||||||
|
* @throws Error if stdin is invalid or missing required fields
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const input = await parseStdin();
|
||||||
|
* console.log('Working directory:', input.cwd);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function parseStdin(): Promise<HookInput> {
|
||||||
|
const stdin = await Bun.stdin.text();
|
||||||
|
|
||||||
|
if (!stdin || stdin.trim() === '') {
|
||||||
|
throw new Error('No input received from stdin');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const input = JSON.parse(stdin) as HookInput;
|
||||||
|
|
||||||
|
if (!input.cwd || typeof input.cwd !== 'string') {
|
||||||
|
throw new Error('Invalid input: missing or invalid cwd field');
|
||||||
|
}
|
||||||
|
|
||||||
|
return input;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SyntaxError) {
|
||||||
|
throw new Error('Invalid JSON from stdin: ' + error.message);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safe error formatting for hook output
|
||||||
|
*
|
||||||
|
* Formats error for display to user without leaking implementation details.
|
||||||
|
*
|
||||||
|
* @param error Error object or unknown
|
||||||
|
* @param prefix Optional prefix for error message
|
||||||
|
* @returns Formatted error string
|
||||||
|
*/
|
||||||
|
export function formatError(error: unknown, prefix = 'Hook error'): string {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
return `[ERROR] ${prefix}: ${msg}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performance monitoring helper
|
||||||
|
*
|
||||||
|
* Logs warning if execution exceeds target duration.
|
||||||
|
* Use to enforce ADR-0010 performance requirements (<50ms command hooks).
|
||||||
|
*
|
||||||
|
* @param startTime Start time from Date.now()
|
||||||
|
* @param targetMs Target duration in milliseconds
|
||||||
|
* @param hookName Name of hook for warning message
|
||||||
|
*/
|
||||||
|
export function checkPerformance(
|
||||||
|
startTime: number,
|
||||||
|
targetMs: number,
|
||||||
|
hookName: string,
|
||||||
|
): void {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
if (duration > targetMs) {
|
||||||
|
console.warn(
|
||||||
|
`[WARNING] Slow ${hookName} hook: ${duration}ms (target: ${targetMs}ms)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
hooks/utils/index.ts
Normal file
23
hooks/utils/index.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Hook Utilities
|
||||||
|
*
|
||||||
|
* Shared utilities for Claude Code command hooks.
|
||||||
|
* Includes input parsing, configuration loading, error handling, and runtime checks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
checkHookEnabled,
|
||||||
|
type HookConfig,
|
||||||
|
loadHookConfig,
|
||||||
|
} from './config-loader.js';
|
||||||
|
export {
|
||||||
|
checkPerformance,
|
||||||
|
formatError,
|
||||||
|
type HookInput,
|
||||||
|
parseStdin,
|
||||||
|
} from './hook-input.js';
|
||||||
|
export {
|
||||||
|
checkBunVersion,
|
||||||
|
ensureBunInstalled,
|
||||||
|
ensureToolsInstalled,
|
||||||
|
} from './runtime-check.js';
|
||||||
146
hooks/utils/runtime-check.ts
Normal file
146
hooks/utils/runtime-check.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* Runtime Check Utilities
|
||||||
|
*
|
||||||
|
* Validates runtime requirements for hooks (e.g., Bun installation).
|
||||||
|
* Provides clear, actionable error messages when requirements aren't met.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Bun is installed and available in PATH
|
||||||
|
*
|
||||||
|
* @throws Error with installation instructions if Bun is not found
|
||||||
|
*/
|
||||||
|
export function ensureBunInstalled(): void {
|
||||||
|
try {
|
||||||
|
// Use 'bun --version' for cross-platform compatibility (works on Windows, macOS, Linux)
|
||||||
|
execSync('bun --version', { stdio: 'pipe' });
|
||||||
|
} catch {
|
||||||
|
const errorMessage = [
|
||||||
|
'',
|
||||||
|
'═'.repeat(70),
|
||||||
|
'❌ BUN RUNTIME NOT FOUND',
|
||||||
|
'═'.repeat(70),
|
||||||
|
'',
|
||||||
|
'This workflow hook requires Bun to be installed.',
|
||||||
|
'',
|
||||||
|
'📦 Install Bun:',
|
||||||
|
'',
|
||||||
|
' macOS/Linux:',
|
||||||
|
' curl -fsSL https://bun.sh/install | bash',
|
||||||
|
'',
|
||||||
|
' Windows:',
|
||||||
|
' powershell -c "irm bun.sh/install.ps1|iex"',
|
||||||
|
'',
|
||||||
|
' Or via npm:',
|
||||||
|
' npm install -g bun',
|
||||||
|
'',
|
||||||
|
' Or via Homebrew (macOS):',
|
||||||
|
' brew install oven-sh/bun/bun',
|
||||||
|
'',
|
||||||
|
'🔗 More info: https://bun.sh',
|
||||||
|
'',
|
||||||
|
'⚠️ After installing, restart your terminal and try again.',
|
||||||
|
'',
|
||||||
|
'═'.repeat(70),
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
console.error(errorMessage);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check Bun version meets minimum requirement
|
||||||
|
*
|
||||||
|
* @param minVersion Minimum required version (e.g., "1.0.0")
|
||||||
|
* @returns true if version is sufficient, false otherwise
|
||||||
|
*/
|
||||||
|
export function checkBunVersion(minVersion: string): boolean {
|
||||||
|
try {
|
||||||
|
const version = execSync('bun --version', {
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: 'pipe',
|
||||||
|
}).trim();
|
||||||
|
|
||||||
|
return compareVersions(version, minVersion) >= 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip pre-release and build metadata from semver string
|
||||||
|
*
|
||||||
|
* @param version Version string (e.g., "1.0.0-beta", "1.0.0+build")
|
||||||
|
* @returns Clean version (e.g., "1.0.0")
|
||||||
|
*/
|
||||||
|
function stripSemverMetadata(version: string): string {
|
||||||
|
return version.split(/[-+]/)[0] || version;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare two semantic versions
|
||||||
|
*
|
||||||
|
* Strips pre-release and build metadata before comparison.
|
||||||
|
* Examples: "1.0.0-beta" → "1.0.0", "1.0.0+build" → "1.0.0"
|
||||||
|
*
|
||||||
|
* @param a Version string (e.g., "1.2.3", "1.2.3-beta")
|
||||||
|
* @param b Version string (e.g., "1.0.0", "1.0.0+build")
|
||||||
|
* @returns -1 if a < b, 0 if equal, 1 if a > b
|
||||||
|
*/
|
||||||
|
function compareVersions(a: string, b: string): number {
|
||||||
|
const aParts = stripSemverMetadata(a).split('.').map(Number);
|
||||||
|
const bParts = stripSemverMetadata(b).split('.').map(Number);
|
||||||
|
|
||||||
|
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
|
||||||
|
const aPart = aParts[i] || 0;
|
||||||
|
const bPart = bParts[i] || 0;
|
||||||
|
|
||||||
|
if (aPart > bPart) return 1;
|
||||||
|
if (aPart < bPart) return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure required command-line tools are installed
|
||||||
|
*
|
||||||
|
* @param tools Array of required tools (e.g., ['git', 'tsc'])
|
||||||
|
* @throws Error with installation instructions if any tool is missing
|
||||||
|
*/
|
||||||
|
export function ensureToolsInstalled(tools: string[]): void {
|
||||||
|
const missing: string[] = [];
|
||||||
|
// Use platform-specific command: 'where' on Windows, 'which' on Unix-like systems
|
||||||
|
const checkCommand = process.platform === 'win32' ? 'where' : 'which';
|
||||||
|
|
||||||
|
for (const tool of tools) {
|
||||||
|
try {
|
||||||
|
execSync(`${checkCommand} ${tool}`, { stdio: 'pipe' });
|
||||||
|
} catch {
|
||||||
|
missing.push(tool);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
const errorMessage = [
|
||||||
|
'',
|
||||||
|
'═'.repeat(70),
|
||||||
|
'❌ MISSING REQUIRED TOOLS',
|
||||||
|
'═'.repeat(70),
|
||||||
|
'',
|
||||||
|
`The following tools are required but not found: ${missing.join(', ')}`,
|
||||||
|
'',
|
||||||
|
'Please install the missing tools and try again.',
|
||||||
|
'',
|
||||||
|
'═'.repeat(70),
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
console.error(errorMessage);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
430
hooks/utils/super-claude-config-loader.ts
Normal file
430
hooks/utils/super-claude-config-loader.ts
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
/**
|
||||||
|
* Super Claude Configuration Loader
|
||||||
|
*
|
||||||
|
* Loads unified plugin configuration from super-claude-config.json files
|
||||||
|
* with support for:
|
||||||
|
* - Plugin defaults (plugins/{plugin}/super-claude-config.json)
|
||||||
|
* - Project overrides (.claude/super-claude-config.json)
|
||||||
|
* - Legacy skill-rules.json backwards compatibility
|
||||||
|
* - Environment variable overrides (highest priority)
|
||||||
|
* - Deep merge with caching for performance (<50ms)
|
||||||
|
*
|
||||||
|
* Loading priority: env vars > project overrides > plugin defaults
|
||||||
|
*
|
||||||
|
* @see {@link https://github.com/jbabin91/super-claude} for documentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
HookConfig,
|
||||||
|
LegacySkillRules,
|
||||||
|
PluginConfig,
|
||||||
|
ProjectConfig,
|
||||||
|
ResolvedConfig,
|
||||||
|
SkillConfig,
|
||||||
|
} from './config-types.js';
|
||||||
|
import {
|
||||||
|
validateLegacySkillRules,
|
||||||
|
validatePluginConfig,
|
||||||
|
validateProjectConfig,
|
||||||
|
} from './config-validation.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration cache for performance
|
||||||
|
* Key: pluginName:cwd
|
||||||
|
*/
|
||||||
|
const configCache = new Map<string, ResolvedConfig>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep merge two objects
|
||||||
|
*
|
||||||
|
* Arrays are replaced entirely (not merged item-by-item)
|
||||||
|
* Nested objects are merged recursively
|
||||||
|
*
|
||||||
|
* @param target Target object
|
||||||
|
* @param source Source object with overrides
|
||||||
|
* @returns Merged object
|
||||||
|
*/
|
||||||
|
function deepMerge<T extends Record<string, unknown>>(
|
||||||
|
target: T,
|
||||||
|
source: Partial<T>,
|
||||||
|
): T {
|
||||||
|
const result = { ...target };
|
||||||
|
|
||||||
|
for (const key in source) {
|
||||||
|
const sourceValue = source[key];
|
||||||
|
const targetValue = result[key];
|
||||||
|
|
||||||
|
if (sourceValue === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arrays: replace entirely
|
||||||
|
if (Array.isArray(sourceValue)) {
|
||||||
|
result[key] = sourceValue as T[Extract<keyof T, string>];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Objects: merge recursively
|
||||||
|
if (
|
||||||
|
typeof sourceValue === 'object' &&
|
||||||
|
sourceValue !== null &&
|
||||||
|
typeof targetValue === 'object' &&
|
||||||
|
targetValue !== null &&
|
||||||
|
!Array.isArray(targetValue)
|
||||||
|
) {
|
||||||
|
result[key] = deepMerge(
|
||||||
|
targetValue as Record<string, unknown>,
|
||||||
|
sourceValue as Record<string, unknown>,
|
||||||
|
) as T[Extract<keyof T, string>];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Primitives: replace
|
||||||
|
result[key] = sourceValue as T[Extract<keyof T, string>];
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load JSON file safely
|
||||||
|
*
|
||||||
|
* @param filePath Path to JSON file
|
||||||
|
* @returns Parsed JSON or null if not found/invalid
|
||||||
|
*/
|
||||||
|
function loadJsonFile(filePath: string): unknown {
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = readFileSync(filePath, 'utf8');
|
||||||
|
return JSON.parse(content) as unknown;
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
console.error(`[ERROR] Failed to load ${filePath}: ${msg}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find plugin root directory
|
||||||
|
*
|
||||||
|
* Searches upward from current file to find plugins directory
|
||||||
|
*
|
||||||
|
* @param cwd Current working directory
|
||||||
|
* @returns Path to plugin root or null if not found
|
||||||
|
*/
|
||||||
|
function findPluginRoot(cwd: string): string | null {
|
||||||
|
// Try to find plugins directory by searching upward
|
||||||
|
let current = cwd;
|
||||||
|
const root = path.parse(current).root;
|
||||||
|
|
||||||
|
while (current !== root) {
|
||||||
|
const pluginsDir = path.join(current, 'plugins');
|
||||||
|
if (existsSync(pluginsDir)) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
current = path.dirname(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load plugin default configuration
|
||||||
|
*
|
||||||
|
* Tries to load from:
|
||||||
|
* 1. plugins/{plugin}/super-claude-config.json
|
||||||
|
* 2. plugins/{plugin}/skill-rules.json (legacy, with warning)
|
||||||
|
*
|
||||||
|
* @param cwd Current working directory
|
||||||
|
* @param pluginName Plugin name
|
||||||
|
* @returns Plugin configuration or empty config
|
||||||
|
*/
|
||||||
|
function loadPluginDefaults(
|
||||||
|
cwd: string,
|
||||||
|
pluginName: string,
|
||||||
|
): Partial<PluginConfig> {
|
||||||
|
const pluginRoot = findPluginRoot(cwd);
|
||||||
|
if (!pluginRoot) {
|
||||||
|
console.error(
|
||||||
|
`[WARNING] Could not find plugin root from ${cwd}, using empty defaults`,
|
||||||
|
);
|
||||||
|
return { plugin: pluginName, skills: {}, hooks: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginDir = path.join(pluginRoot, 'plugins', pluginName);
|
||||||
|
|
||||||
|
// Try new format first
|
||||||
|
const configPath = path.join(pluginDir, 'super-claude-config.json');
|
||||||
|
const configData = loadJsonFile(configPath);
|
||||||
|
|
||||||
|
if (configData) {
|
||||||
|
const result = validatePluginConfig(configData);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
console.error(
|
||||||
|
`[ERROR] Invalid plugin config in ${pluginName}:\n${result.errors}`,
|
||||||
|
);
|
||||||
|
return { plugin: pluginName, skills: {}, hooks: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try legacy format
|
||||||
|
const legacyPath = path.join(pluginDir, 'skill-rules.json');
|
||||||
|
const legacyData = loadJsonFile(legacyPath);
|
||||||
|
|
||||||
|
if (legacyData) {
|
||||||
|
console.warn(
|
||||||
|
`[DEPRECATION] ${pluginName} using deprecated skill-rules.json. Migrate to super-claude-config.json`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = validateLegacySkillRules(legacyData);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
console.error(
|
||||||
|
`[ERROR] Invalid legacy config in ${pluginName}:\n${result.errors}`,
|
||||||
|
);
|
||||||
|
return { plugin: pluginName, skills: {}, hooks: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert legacy format to new format
|
||||||
|
return convertLegacyFormat(result.data, pluginName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No config found, use empty defaults
|
||||||
|
return { plugin: pluginName, skills: {}, hooks: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert legacy skill-rules.json to new format
|
||||||
|
*
|
||||||
|
* @param legacy Legacy skill rules
|
||||||
|
* @param pluginName Plugin name
|
||||||
|
* @returns Plugin configuration
|
||||||
|
*/
|
||||||
|
function convertLegacyFormat(
|
||||||
|
legacy: LegacySkillRules,
|
||||||
|
pluginName: string,
|
||||||
|
): Partial<PluginConfig> {
|
||||||
|
const skills: Record<string, SkillConfig> = {};
|
||||||
|
|
||||||
|
for (const [skillName, skillData] of Object.entries(legacy.skills)) {
|
||||||
|
skills[skillName] = {
|
||||||
|
enabled: !legacy.overrides?.disabled?.includes(
|
||||||
|
`${legacy.plugin.namespace}/${skillName}`,
|
||||||
|
),
|
||||||
|
triggers: {
|
||||||
|
keywords: skillData.promptTriggers?.keywords ?? [],
|
||||||
|
patterns: skillData.promptTriggers?.intentPatterns ?? [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
plugin: pluginName,
|
||||||
|
skills,
|
||||||
|
hooks: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load project override configuration
|
||||||
|
*
|
||||||
|
* Loads from .claude/super-claude-config.json
|
||||||
|
*
|
||||||
|
* @param cwd Current working directory
|
||||||
|
* @returns Project configuration or null
|
||||||
|
*/
|
||||||
|
function loadProjectOverrides(cwd: string): ProjectConfig | null {
|
||||||
|
const overridePath = path.join(cwd, '.claude', 'super-claude-config.json');
|
||||||
|
const overrideData = loadJsonFile(overridePath);
|
||||||
|
|
||||||
|
if (!overrideData) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = validateProjectConfig(overrideData);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
console.error(`[ERROR] Invalid project config:\n${result.errors}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply environment variable overrides
|
||||||
|
*
|
||||||
|
* Environment variables have highest priority.
|
||||||
|
* Format: CLAUDE_HOOK_{HOOK_NAME}_ENABLED=true|false
|
||||||
|
*
|
||||||
|
* @param config Configuration to modify
|
||||||
|
* @param pluginName Plugin name (for logging)
|
||||||
|
*/
|
||||||
|
function applyEnvironmentOverrides(
|
||||||
|
config: ResolvedConfig,
|
||||||
|
pluginName: string,
|
||||||
|
): void {
|
||||||
|
// Hook enabled overrides
|
||||||
|
for (const hookName of Object.keys(config.hooks)) {
|
||||||
|
const envKey = `CLAUDE_HOOK_${hookName.replaceAll(/[A-Z]/g, (m) => `_${m}`).toUpperCase()}`;
|
||||||
|
const envValue = process.env[envKey];
|
||||||
|
|
||||||
|
if (envValue !== undefined) {
|
||||||
|
config.hooks[hookName].enabled = envValue === 'true' || envValue === '1';
|
||||||
|
console.error(
|
||||||
|
`[DEBUG] ${pluginName}:${hookName} enabled=${config.hooks[hookName].enabled} (from ${envKey})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and merge configuration for a plugin
|
||||||
|
*
|
||||||
|
* Merge order: plugin defaults → project overrides → env vars
|
||||||
|
*
|
||||||
|
* @param cwd Current working directory
|
||||||
|
* @param pluginName Plugin name
|
||||||
|
* @param useCache Whether to use cached config
|
||||||
|
* @returns Resolved configuration
|
||||||
|
*/
|
||||||
|
export function loadPluginConfig(
|
||||||
|
cwd: string,
|
||||||
|
pluginName: string,
|
||||||
|
useCache = true,
|
||||||
|
): ResolvedConfig {
|
||||||
|
const cacheKey = `${pluginName}:${cwd}`;
|
||||||
|
|
||||||
|
// Check cache
|
||||||
|
if (useCache && configCache.has(cacheKey)) {
|
||||||
|
return configCache.get(cacheKey)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load plugin defaults
|
||||||
|
const pluginDefaults = loadPluginDefaults(cwd, pluginName);
|
||||||
|
|
||||||
|
// Load project overrides
|
||||||
|
const projectOverrides = loadProjectOverrides(cwd);
|
||||||
|
|
||||||
|
// Start with plugin defaults
|
||||||
|
const resolved: ResolvedConfig = {
|
||||||
|
skills: pluginDefaults.skills ?? {},
|
||||||
|
hooks: pluginDefaults.hooks ?? {},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply project overrides for this plugin
|
||||||
|
if (projectOverrides?.[pluginName]) {
|
||||||
|
const pluginOverrides = projectOverrides[pluginName];
|
||||||
|
|
||||||
|
if (pluginOverrides.skills) {
|
||||||
|
for (const [skillName, skillOverride] of Object.entries(
|
||||||
|
pluginOverrides.skills,
|
||||||
|
)) {
|
||||||
|
const defaultSkill = resolved.skills[skillName] ?? {
|
||||||
|
enabled: true,
|
||||||
|
triggers: { keywords: [], patterns: [] },
|
||||||
|
};
|
||||||
|
resolved.skills[skillName] = deepMerge(defaultSkill, skillOverride);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pluginOverrides.hooks) {
|
||||||
|
for (const [hookName, hookOverride] of Object.entries(
|
||||||
|
pluginOverrides.hooks,
|
||||||
|
)) {
|
||||||
|
const defaultHook = resolved.hooks[hookName] ?? { enabled: true };
|
||||||
|
resolved.hooks[hookName] = deepMerge(defaultHook, hookOverride);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply environment variable overrides (highest priority)
|
||||||
|
applyEnvironmentOverrides(resolved, pluginName);
|
||||||
|
|
||||||
|
// Cache result
|
||||||
|
configCache.set(cacheKey, resolved);
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get hook configuration
|
||||||
|
*
|
||||||
|
* @param cwd Current working directory
|
||||||
|
* @param pluginName Plugin name
|
||||||
|
* @param hookName Hook name
|
||||||
|
* @returns Hook configuration
|
||||||
|
*/
|
||||||
|
export function getHookConfig(
|
||||||
|
cwd: string,
|
||||||
|
pluginName: string,
|
||||||
|
hookName: string,
|
||||||
|
): HookConfig {
|
||||||
|
const config = loadPluginConfig(cwd, pluginName);
|
||||||
|
return config.hooks[hookName] ?? { enabled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get skill configuration
|
||||||
|
*
|
||||||
|
* @param cwd Current working directory
|
||||||
|
* @param pluginName Plugin name
|
||||||
|
* @param skillName Skill name
|
||||||
|
* @returns Skill configuration
|
||||||
|
*/
|
||||||
|
export function getSkillConfig(
|
||||||
|
cwd: string,
|
||||||
|
pluginName: string,
|
||||||
|
skillName: string,
|
||||||
|
): SkillConfig {
|
||||||
|
const config = loadPluginConfig(cwd, pluginName);
|
||||||
|
return (
|
||||||
|
config.skills[skillName] ?? {
|
||||||
|
enabled: true,
|
||||||
|
triggers: { keywords: [], patterns: [] },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if hook is enabled
|
||||||
|
*
|
||||||
|
* Convenience function that exits cleanly if hook is disabled.
|
||||||
|
*
|
||||||
|
* @param cwd Current working directory
|
||||||
|
* @param pluginName Plugin name
|
||||||
|
* @param hookName Hook name
|
||||||
|
* @returns true if enabled, exits if disabled
|
||||||
|
*/
|
||||||
|
export function checkHookEnabled(
|
||||||
|
cwd: string,
|
||||||
|
pluginName: string,
|
||||||
|
hookName: string,
|
||||||
|
): boolean {
|
||||||
|
const config = getHookConfig(cwd, pluginName, hookName);
|
||||||
|
|
||||||
|
if (!config.enabled) {
|
||||||
|
console.error(`[DEBUG] ${pluginName}:${hookName} disabled by config`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear configuration cache
|
||||||
|
*
|
||||||
|
* Useful for testing or when config files are modified
|
||||||
|
*/
|
||||||
|
export function clearConfigCache(): void {
|
||||||
|
configCache.clear();
|
||||||
|
}
|
||||||
145
plugin.lock.json
Normal file
145
plugin.lock.json
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
{
|
||||||
|
"$schema": "internal://schemas/plugin.lock.v1.json",
|
||||||
|
"pluginId": "gh:jbabin91/super-claude:plugins/workflow",
|
||||||
|
"normalized": {
|
||||||
|
"repo": null,
|
||||||
|
"ref": "refs/tags/v20251128.0",
|
||||||
|
"commit": "6104a34e324b266a4933fee2c1e56dde8d941dea",
|
||||||
|
"treeHash": "339bc1d8f318fa93d452c5a4dd8b15fd2b4e65c32210a664932f9ce6ee701abf",
|
||||||
|
"generatedAt": "2025-11-28T10:17:58.189690Z",
|
||||||
|
"toolVersion": "publish_plugins.py@0.2.0"
|
||||||
|
},
|
||||||
|
"origin": {
|
||||||
|
"remote": "git@github.com:zhongweili/42plugin-data.git",
|
||||||
|
"branch": "master",
|
||||||
|
"commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390",
|
||||||
|
"repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data"
|
||||||
|
},
|
||||||
|
"manifest": {
|
||||||
|
"name": "workflow",
|
||||||
|
"description": "Development workflow enhancements including OpenSpec proposal management, skill auto-activation, and command hooks for session checklists, type checking, commit guards, and branch name validation.",
|
||||||
|
"version": "0.5.0"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "README.md",
|
||||||
|
"sha256": "f2c016ffacfe60e4489e885a0c66b38a7e5f527f838888d7798465e64d8d9eb0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "hooks/branch-name-validator.ts",
|
||||||
|
"sha256": "9d7be48def1f7ef5a08e56eabce6933fe023976271dfc1b9acb32fe47d85c747"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "hooks/skill-activation-prompt.ts",
|
||||||
|
"sha256": "a9ecafcd6b4ae4eb26cf52d5ae66db44719ccf0e0d50023b291de38d40c856b7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "hooks/git-commit-guard.ts",
|
||||||
|
"sha256": "555c79a6b5140a5810e9dc0c8f37b5118627cb2bf62319cb4468aa2c2c352275"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "hooks/hooks.json",
|
||||||
|
"sha256": "192f7fb820e04adf7a4e40c7254f07e688ef96c589e66c29f129f4a72c8c7194"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "hooks/session-checklist.ts",
|
||||||
|
"sha256": "a101bf32f7056f730813d0129d979b5edd7796dd92954b453e1819beb2191dd3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "hooks/session-start.ts",
|
||||||
|
"sha256": "5a8655b6480985151a546823b629b260eee88ed936c0b2da53199983247d31e1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "hooks/type-checker.ts",
|
||||||
|
"sha256": "877f2d5cdcb5ccb7b4ee1404283706b4aea2d43edbf8cdfb4695169e0e3d01c7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "hooks/utils/super-claude-config-loader.ts",
|
||||||
|
"sha256": "0a590a2a65e0a37fd28911eb5b5f74c320a39b85e6fdae7b65266143556e599c"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "hooks/utils/config-types.ts",
|
||||||
|
"sha256": "b16056fea5f3d7a6fb4b9a7366f7600bac082a9f279e12343655329f1cd0d3e7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "hooks/utils/runtime-check.ts",
|
||||||
|
"sha256": "496dd5a0a7c233bd8f369db5c518d962709d7acf7f1ec1560fd09652c1c29a5a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "hooks/utils/config-validation.ts",
|
||||||
|
"sha256": "55388ebbf3298715a4c8074d1eb08780ac8125077e170a38502a0e6c17b6bf26"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "hooks/utils/index.ts",
|
||||||
|
"sha256": "e550830293af79a135675654b397580487509de790f1554162c3ccd103cdb4e4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "hooks/utils/config-loader.ts",
|
||||||
|
"sha256": "cc3f7aa4bdb66e35caeeb4f4a29dfb6236c744c49db1b973cdbffa95c1ea264b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "hooks/utils/hook-input.ts",
|
||||||
|
"sha256": "8a402b8192cecd781678ae3dab8c01d02af1df1c24f7a0fee5a74e2f30072f0c"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ".claude-plugin/plugin.json",
|
||||||
|
"sha256": "d405a243791af16c98a1eb8cd1dba5e134f28883c5ae7457a23557833612781a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "commands/generate-skill-rules.md",
|
||||||
|
"sha256": "b483e5044ec21cac250a970b97a034998c5c5e9d8e317c59e15c3915e0df7a7a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "commands/configure.md",
|
||||||
|
"sha256": "76654fd174659b36520ea901d1409ac672e660ff203e323ab23494ad178b34d3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "commands/openspec/archive.md",
|
||||||
|
"sha256": "c4b4758098d7d515bcaaea05e0b197d11e583d79c52e7e507e3c7fde40a25c25"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "commands/openspec/work.md",
|
||||||
|
"sha256": "0fdff9d945daa3f49ed146d11d52d2da3566106271df678a00716502538a8987"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "commands/openspec/status.md",
|
||||||
|
"sha256": "43250a9afaca3b2eabb767df21898010c460c3ffe8016e233de4c6f00713b1a5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "commands/openspec/apply.md",
|
||||||
|
"sha256": "93275001081c4ca981ce503dc9b0249eeaa3fed5415dcc7a6ee078360113085c"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "commands/openspec/done.md",
|
||||||
|
"sha256": "085a63f5ccace79886913309c6d225e497605e0e8bcbbf7a987da519f957a60e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "commands/openspec/init.md",
|
||||||
|
"sha256": "b8d1b4643d23d3c397f392f2d5eb019b53aa3fd0d433b1d1d5141588449ee3d2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "commands/openspec/checkpoint.md",
|
||||||
|
"sha256": "d0f03fcf0dd05a732f348d37c44939469dace6cd1bb5223d47808833f8cd0680"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "commands/openspec/update.md",
|
||||||
|
"sha256": "b16a933f9187634f7e2623cbf41daf1835fceec85b59d54fe56dbb2794aea75e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "commands/openspec/proposal.md",
|
||||||
|
"sha256": "721acdbe34d284da66b7491201a9472f4ae46678c9aa1d1ad60c293c2daea354"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/.gitkeep",
|
||||||
|
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dirSha256": "339bc1d8f318fa93d452c5a4dd8b15fd2b4e65c32210a664932f9ce6ee701abf"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"scannedAt": null,
|
||||||
|
"scannerVersion": null,
|
||||||
|
"flags": []
|
||||||
|
}
|
||||||
|
}
|
||||||
0
skills/.gitkeep
Normal file
0
skills/.gitkeep
Normal file
Reference in New Issue
Block a user