Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ dots doctor # Run diagnostics
│ └── install/ # Component installers
├── agents/
│ ├── skills/ # Cross-agent skills (SKILL.md per skill)
│ └── custom/ # Custom agent types (.md per agent → ~/.claude/agents/)
│ ├── custom/ # Custom agent types (.md per agent → ~/.claude/agents/)
│ └── hooks/ # Claude Code hooks (merged into ~/.claude/settings.json)
├── cmd/ # 22 standalone utilities
└── pkg/ # Shared utilities (log, run, cache, path)
```
Expand Down Expand Up @@ -93,7 +94,7 @@ dots doctor # Run diagnostics
| vim | Vim configuration |
| hammerspoon | Window management |
| osx | macOS defaults |
| agents | Agent skills and custom agents (symlinks agents/skills → ~/.claude/skills + ~/.agents/skills, agents/custom → ~/.claude/agents) |
| agents | Agent skills, custom agents, and hooks (symlinks skills/custom, merges hooks into ~/.claude/settings.json) |

## Writing Skills / Slash Commands

Expand Down
20 changes: 17 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ dots docker stop-all # Stop all Docker containers

| Component | What it installs |
|-----------|------------------|
| `agents` | Agent skills and custom agents (symlinks `agents/skills/` → `~/.claude/skills/` + `~/.agents/skills/`, `agents/custom/` → `~/.claude/agents/`) |
| `agents` | Agent skills, custom agents, and hooks (symlinks skills/custom, merges `agents/hooks/hooks.json` into `~/.claude/settings.json`) |
| `bin` | Custom shell scripts and Go utilities to `~/bin` |
| `git` | `.gitconfig`, `.gitignore_global`, git extensions |
| `home` | Dotfiles symlinked to `~/` (`.zshrc`, `.vimrc`, `.tmux.conf`, `.gitconfig`, etc.) |
Expand Down Expand Up @@ -124,6 +124,18 @@ Dots also ships 3 reusable custom agent definitions in `agents/custom/`. These a

Run `dots install agents` to symlink them to `~/.claude/agents/`.

### Hooks

Claude Code hooks in `agents/hooks/` provide deterministic automation that fires at specific lifecycle events. Unlike skills (which Claude chooses to use), hooks always execute.

| Hook | Event | Purpose |
|------|-------|---------|
| `protect-files.sh` | PreToolUse (Edit/Write) | Blocks writes to `.env`, keys, credentials |
| `auto-format.sh` | PostToolUse (Edit/Write) | Auto-formats files (gofmt, prettier, rubocop, ruff) |
| `compact-context.sh` | SessionStart (compact) | Re-injects context from `.claude/compact-context.md` after compaction |

Run `dots install agents` to merge hooks into `~/.claude/settings.json`.

## Project Structure

```
Expand Down Expand Up @@ -177,8 +189,10 @@ Run `dots install agents` to symlink them to `~/.claude/agents/`.
├── agents/ # Agent configuration
│ ├── skills/ # 39 reusable skills (SKILL.md per skill)
│ └── custom/ # 3 custom agent types (.md per agent)
│ └── tests/ # Skill test suite
│ ├── custom/ # 3 custom agent types (.md per agent)
│ │ └── tests/ # Skill test suite
│ └── hooks/ # Claude Code hooks (merged into settings.json)
│ └── scripts/ # Hook shell scripts
└── openspec/ # Change proposal system
```
Expand Down
39 changes: 39 additions & 0 deletions agents/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "$DOTS/agents/hooks/scripts/protect-files.sh",
"timeout": 5
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "$DOTS/agents/hooks/scripts/auto-format.sh",
"timeout": 15
}
]
}
],
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "$DOTS/agents/hooks/scripts/compact-context.sh"
}
]
}
]
}
}
39 changes: 39 additions & 0 deletions agents/hooks/scripts/auto-format.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#!/bin/bash
# PostToolUse hook: Auto-format files after Write/Edit
# Runs the appropriate formatter based on file extension

if ! command -v jq >/dev/null 2>&1; then
exit 0
fi

INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

if [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ]; then
exit 0
fi

case "$FILE_PATH" in
*.go)
command -v gofmt >/dev/null 2>&1 && gofmt -w "$FILE_PATH" 2>/dev/null
;;
*.js|*.jsx|*.ts|*.tsx|*.css|*.scss|*.json|*.yaml|*.yml)
# Only run prettier if installed locally in the project
PROJECT_DIR=$(git -C "$(dirname "$FILE_PATH")" rev-parse --show-toplevel 2>/dev/null)
if [ -n "$PROJECT_DIR" ] && [ -f "$PROJECT_DIR/node_modules/.bin/prettier" ]; then
"$PROJECT_DIR/node_modules/.bin/prettier" --write "$FILE_PATH" 2>/dev/null
fi
;;
*.rb)
# Only run rubocop if configured in the project (safe autocorrect only)
PROJECT_DIR=$(git -C "$(dirname "$FILE_PATH")" rev-parse --show-toplevel 2>/dev/null)
if [ -n "$PROJECT_DIR" ] && [ -f "$PROJECT_DIR/.rubocop.yml" ]; then
command -v rubocop >/dev/null 2>&1 && rubocop -a --force-exclusion "$FILE_PATH" 2>/dev/null
fi
;;
*.py)
command -v ruff >/dev/null 2>&1 && ruff format "$FILE_PATH" 2>/dev/null
;;
esac

exit 0
25 changes: 25 additions & 0 deletions agents/hooks/scripts/compact-context.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/bin/bash
# SessionStart hook (compact): Re-inject critical context after compaction
# Stdout from this hook is added as system context

if ! command -v jq >/dev/null 2>&1; then
exit 0
fi

# Check for project-level context file
CWD=$(cat | jq -r '.cwd // empty')
if [ -n "$CWD" ]; then
CONTEXT_FILE="$CWD/.claude/compact-context.md"
if [ -f "$CONTEXT_FILE" ]; then
cat "$CONTEXT_FILE"
exit 0
fi
fi

# Fallback: inject user-level context
USER_CONTEXT="$HOME/.claude/compact-context.md"
if [ -f "$USER_CONTEXT" ]; then
cat "$USER_CONTEXT"
fi

exit 0
35 changes: 35 additions & 0 deletions agents/hooks/scripts/protect-files.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#!/bin/bash
# PreToolUse hook: Block writes to sensitive files
# Exit 2 = block with feedback to Claude

if ! command -v jq >/dev/null 2>&1; then
echo "Hook error: jq is required but not installed" >&2
exit 2
fi

INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

if [ -z "$FILE_PATH" ]; then
exit 0
fi

BASENAME=$(basename "$FILE_PATH")

# Protected file patterns
case "$BASENAME" in
.env|.env.*)
echo "Blocked: cannot write to environment file '$FILE_PATH'" >&2
exit 2
;;
*.pem|*.p12|*.pfx)
echo "Blocked: cannot write to certificate file '$FILE_PATH'" >&2
exit 2
;;
id_rsa*|id_ed25519*|id_ecdsa*)
echo "Blocked: cannot write to SSH key file '$FILE_PATH'" >&2
exit 2
;;
esac

exit 0
73 changes: 73 additions & 0 deletions cli/commands/install/agents.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package install

import (
"encoding/json"
"os"
"strings"

"github.com/drn/dots/cli/link"
"github.com/drn/dots/pkg/log"
Expand All @@ -26,6 +28,9 @@ func (i Install) Agents() {
link.Soft(skillsSource, path.FromHome(".claude/skills"))
link.Soft(customSource, path.FromHome(".claude/agents"))

// Claude Code: merge hooks into ~/.claude/settings.json
installHooks()

// Codex: ensure ~/.agents exists and symlink skills
agentsDir := path.FromHome(".agents")
if _, err := os.Stat(agentsDir); os.IsNotExist(err) {
Expand All @@ -36,3 +41,71 @@ func (i Install) Agents() {
}
link.Soft(skillsSource, path.FromHome(".agents/skills"))
}

// installHooks merges hooks from agents/hooks/hooks.json into ~/.claude/settings.json.
// Hook commands containing $DOTS are resolved to absolute paths at install time.
// Any existing hooks in settings.json are replaced (dots owns the hooks key).
func installHooks() {
mergeHooksIntoSettings(
path.FromDots("agents/hooks/hooks.json"),
path.FromHome(".claude/settings.json"),
path.Dots(),
)
}

// mergeHooksIntoSettings reads hooks from hooksPath, resolves $DOTS references
// to dotsDir, and writes them into settingsPath preserving other settings keys.
func mergeHooksIntoSettings(hooksPath, settingsPath, dotsDir string) {
// Read hooks config
hooksData, err := os.ReadFile(hooksPath)
if err != nil {
log.Warning("No hooks config found at %s", path.Pretty(hooksPath))
return
}

// Resolve $DOTS to absolute path so hooks work regardless of shell env
resolved := strings.ReplaceAll(string(hooksData), "$DOTS", dotsDir)

var hooksConfig map[string]interface{}
if err := json.Unmarshal([]byte(resolved), &hooksConfig); err != nil {
log.Error("Failed to parse hooks config: %s", err.Error())
return
}

hooks, ok := hooksConfig["hooks"]
if !ok {
log.Warning("No 'hooks' key found in hooks config")
return
}

// Read existing settings
settings := map[string]interface{}{}
if data, err := os.ReadFile(settingsPath); err == nil {
if err := json.Unmarshal(data, &settings); err != nil {
log.Error("Failed to parse existing settings: %s", err.Error())
return
}
}

// Warn if existing hooks will be replaced
if _, exists := settings["hooks"]; exists {
log.Warning("Replacing existing hooks in %s", path.Pretty(settingsPath))
}

// Replace hooks key (dots owns this key)
settings["hooks"] = hooks

// Write back
output, err := json.MarshalIndent(settings, "", " ")
if err != nil {
log.Error("Failed to marshal settings: %s", err.Error())
return
}

if err := os.WriteFile(settingsPath, append(output, '\n'), 0644); err != nil {
log.Error("Failed to write settings: %s", err.Error())
return
}

log.Info("Merged hooks into %s", path.Pretty(settingsPath))
}
Loading
Loading