Skip to content
Merged
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
14 changes: 14 additions & 0 deletions .github/workflows/plugin-load-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,25 @@ on:
- .claude-plugin/**
- hooks/**
- skills/**
- tests/test_always_on_hooks.py
- .github/workflows/plugin-load-check.yml
push:
branches: [main]

jobs:
hook-parity:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Test native hook implementations
run: python -m unittest tests.test_always_on_hooks -v

load:
runs-on: ubuntu-latest
steps:
Expand Down
44 changes: 44 additions & 0 deletions hooks/always-on.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// SessionStart hook: injects the full i-have-adhd ruleset when the user has
// opted in by creating $CLAUDE_CONFIG_DIR/.i-have-adhd-always (default ~/.claude).
// Never blocks session start: any failure exits 0.
//
// Runs under Node so it works on macOS, Linux, and Windows without depending on
// a POSIX shell (`sh`) being on PATH. The hook uses exec form, so Claude Code
// passes the script path directly without PowerShell or POSIX-shell parsing.
// Native sh and PowerShell implementations remain available as fallbacks.

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";

try {
const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
const flagPath = path.join(claudeDir, ".i-have-adhd-always");

// Only fire when the user has opted in.
if (!fs.existsSync(flagPath)) process.exit(0);

// Resolve SKILL.md relative to this script's own location, not a trusted env var.
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const skillPath = path.join(scriptDir, "..", "skills", "i-have-adhd", "SKILL.md");
if (!fs.existsSync(skillPath)) process.exit(0);

// Strip a leading YAML frontmatter block (--- ... --- at the very top of file).
const body = fs
.readFileSync(skillPath, "utf8")
.replace(
/^---[^\S\r\n]*\r?\n[\s\S]*?\r?\n---[^\S\r\n]*(?:\r?\n|$)/,
"",
)
.replace(/(?:\r?\n)+$/, "");

process.stdout.write(
"ADHD MODE ACTIVE (always-on). The ruleset below applies to every response. " +
'"stop adhd mode" turns it off for this session; ' +
`delete ${flagPath} to turn always-on off for good.\n\n${body}\n`,
);
} catch {
// Never block session start.
process.exit(0);
}
49 changes: 49 additions & 0 deletions hooks/always-on.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# SessionStart hook fallback for Windows PowerShell. Injects the full
# i-have-adhd ruleset when the user has opted in by creating
# $CLAUDE_CONFIG_DIR/.i-have-adhd-always (default ~/.claude).
# Never blocks session start: any failure exits 0.

try {
$claudeDir = if ($env:CLAUDE_CONFIG_DIR) {
$env:CLAUDE_CONFIG_DIR
} else {
Join-Path ([Environment]::GetFolderPath("UserProfile")) ".claude"
}
$flagPath = Join-Path $claudeDir ".i-have-adhd-always"

if (-not (Test-Path -LiteralPath $flagPath -PathType Leaf)) {
exit 0
}

$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$skillPath = Join-Path $scriptDir "../skills/i-have-adhd/SKILL.md"
if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) {
exit 0
}

$lines = [System.IO.File]::ReadAllLines($skillPath)
$bodyStart = 0

if ($lines.Length -gt 0 -and $lines[0] -match '^---\s*$') {
$bodyStart = $lines.Length
for ($i = 1; $i -lt $lines.Length; $i++) {
if ($lines[$i] -match '^---\s*$') {
$bodyStart = $i + 1
break
}
}
}

$body = if ($bodyStart -lt $lines.Length) {
[string]::Join([Environment]::NewLine, $lines[$bodyStart..($lines.Length - 1)])
} else {
""
}

$banner = 'ADHD MODE ACTIVE (always-on). The ruleset below applies to every response. ' +
'"stop adhd mode" turns it off for this session; delete '
[Console]::Out.Write($banner + $flagPath + " to turn always-on off for good.`n`n" + $body + "`n")
} catch {
# Never block session start.
exit 0
}
5 changes: 2 additions & 3 deletions hooks/always-on.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@
# opted in by creating $CLAUDE_CONFIG_DIR/.i-have-adhd-always (default ~/.claude).
# Never blocks session start: any failure exits 0.
#
# Pure POSIX sh so it runs anywhere Claude Code runs a command hook (sh on
# macOS/Linux, Git Bash on Windows) without depending on a Node install being
# on PATH.
# POSIX fallback for environments where the default Node hook cannot run. It
# works with sh on macOS/Linux and Git Bash on Windows without a Node install.

claude_dir="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
flag_path="$claude_dir/.i-have-adhd-always"
Expand Down
5 changes: 3 additions & 2 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@
"hooks": [
{
"type": "command",
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/always-on.sh\"",
"command": "node",
"args": ["${CLAUDE_PLUGIN_ROOT}/hooks/always-on.mjs"],
"timeout": 5,
"statusMessage": "Checking i-have-adhd always-on flag..."
}
]
}
]
}
}
}
96 changes: 96 additions & 0 deletions tests/test_always_on_hooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import json
import os
import shutil
import subprocess
import tempfile
import unittest
from pathlib import Path


ROOT = Path(__file__).resolve().parents[1]


class AlwaysOnHookTest(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.temp_dir.cleanup)
self.plugin_root = Path(self.temp_dir.name) / "plugin"
shutil.copytree(ROOT / "hooks", self.plugin_root / "hooks")
shutil.copytree(ROOT / "skills", self.plugin_root / "skills")
self.config_dir = Path(self.temp_dir.name) / "claude config"
self.config_dir.mkdir()

def runtimes(self):
runtimes = []
if node := shutil.which("node"):
runtimes.append(("node", [node, self.plugin_root / "hooks" / "always-on.mjs"]))
if sh := shutil.which("sh"):
runtimes.append(("sh", [sh, self.plugin_root / "hooks" / "always-on.sh"]))
if powershell := shutil.which("pwsh") or shutil.which("powershell"):
runtimes.append(
(
"powershell",
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
self.plugin_root / "hooks" / "always-on.ps1",
],
)
)
return runtimes

def run_hook(self, command):
env = os.environ.copy()
env["CLAUDE_CONFIG_DIR"] = str(self.config_dir)
return subprocess.run(
[str(part) for part in command],
check=False,
capture_output=True,
text=True,
env=env,
)

def test_hook_is_silent_without_opt_in_flag(self):
self.assertTrue(self.runtimes(), "no hook runtime is available")

for name, command in self.runtimes():
with self.subTest(runtime=name):
result = self.run_hook(command)
self.assertEqual(0, result.returncode)
self.assertEqual("", result.stdout)
self.assertEqual("", result.stderr)

def test_runtimes_strip_frontmatter_with_trailing_whitespace(self):
skill_path = self.plugin_root / "skills" / "i-have-adhd" / "SKILL.md"
skill_path.write_text("--- \nname: fixture\n--- \t\nFixture body.\n")
(self.config_dir / ".i-have-adhd-always").touch()
outputs = {}

for name, command in self.runtimes():
with self.subTest(runtime=name):
result = self.run_hook(command)
self.assertEqual(0, result.returncode)
self.assertEqual("", result.stderr)
normalized = result.stdout.replace("\r\n", "\n")
self.assertNotIn("name: fixture", normalized)
self.assertIn("\n\nFixture body.\n", normalized)
outputs[name] = normalized

self.assertEqual(1, len(set(outputs.values())))

def test_hook_uses_shell_free_node_exec_form(self):
config = json.loads((ROOT / "hooks" / "hooks.json").read_text())
hook = config["hooks"]["SessionStart"][0]["hooks"][0]

self.assertEqual("node", hook["command"])
self.assertEqual(
["${CLAUDE_PLUGIN_ROOT}/hooks/always-on.mjs"],
hook["args"],
)


if __name__ == "__main__":
unittest.main()