mirror of
https://github.com/obra/superpowers.git
synced 2026-08-07 06:38:47 +08:00
Compare commits
3 Commits
fix/render
...
fix/run-ho
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2bbe9ff92 | ||
|
|
d80fc1808a | ||
|
|
431915accb |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -11,9 +11,3 @@ triage/
|
|||||||
# development (see CLAUDE.md / README.md). It is not part of the published
|
# development (see CLAUDE.md / README.md). It is not part of the published
|
||||||
# plugin, so the whole directory is ignored here.
|
# plugin, so the whole directory is ignored here.
|
||||||
evals/
|
evals/
|
||||||
|
|
||||||
# Python
|
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
*.pyo
|
|
||||||
.pytest_cache/
|
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
import os
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
BOOTSTRAP_MARKER = "superpowers:using-superpowers bootstrap for hermes"
|
|
||||||
|
|
||||||
|
|
||||||
def _skills_dir() -> str:
|
|
||||||
"""Locate the stock skills/ tree for either supported install layout.
|
|
||||||
|
|
||||||
- git-clone install (`hermes plugins install obra/superpowers`): the plugin
|
|
||||||
dir is the repo root, so `.hermes-plugin/` and `skills/` are siblings and
|
|
||||||
this module resolves `../skills`.
|
|
||||||
- flattened install (plugin files copied to the plugin dir root): `skills/`
|
|
||||||
sits next to this module.
|
|
||||||
|
|
||||||
Raises loudly when neither matches — a bootstrap that silently skips is how
|
|
||||||
a broken install masquerades as a working one.
|
|
||||||
"""
|
|
||||||
here = os.path.dirname(os.path.realpath(__file__))
|
|
||||||
candidates = (
|
|
||||||
os.path.realpath(os.path.join(here, "..", "skills")),
|
|
||||||
os.path.realpath(os.path.join(here, "skills")),
|
|
||||||
)
|
|
||||||
for cand in candidates:
|
|
||||||
if os.path.isfile(os.path.join(cand, "using-superpowers", "SKILL.md")):
|
|
||||||
return cand
|
|
||||||
raise RuntimeError(
|
|
||||||
"superpowers plugin: cannot find the skills/ tree "
|
|
||||||
f"(looked at {candidates}). Reinstall with "
|
|
||||||
"`hermes plugins install obra/superpowers`."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_frontmatter(content: str) -> str:
|
|
||||||
match = re.match(r"^---\n[\s\S]*?\n---\n([\s\S]*)$", content)
|
|
||||||
return (match.group(1) if match else content).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _build_bootstrap(skills_dir: str) -> str:
|
|
||||||
with open(
|
|
||||||
os.path.join(skills_dir, "using-superpowers", "SKILL.md"),
|
|
||||||
encoding="utf-8",
|
|
||||||
) as f:
|
|
||||||
body = _strip_frontmatter(f.read())
|
|
||||||
|
|
||||||
tools_path = os.path.join(
|
|
||||||
skills_dir, "using-superpowers", "references", "hermes-tools.md"
|
|
||||||
)
|
|
||||||
with open(tools_path, encoding="utf-8") as f:
|
|
||||||
tool_mapping = f.read().strip()
|
|
||||||
|
|
||||||
return (
|
|
||||||
f"<EXTREMELY_IMPORTANT>\n"
|
|
||||||
f"{BOOTSTRAP_MARKER}\n\n"
|
|
||||||
f"You have superpowers.\n\n"
|
|
||||||
f"The using-superpowers skill content is included below and is already "
|
|
||||||
f"loaded for this Hermes session. Follow it now. "
|
|
||||||
f"Do not try to load using-superpowers again.\n\n"
|
|
||||||
f"{body}\n\n"
|
|
||||||
f"## Loading Superpowers Skills on Hermes\n\n"
|
|
||||||
f"Superpowers skills are registered with Hermes' native skill loader: "
|
|
||||||
f'invoke one with `skill_view("superpowers:skill-name")` '
|
|
||||||
f'(for example `skill_view("superpowers:brainstorming")`). '
|
|
||||||
f"If a namespaced lookup returns 'not found', read the skill file "
|
|
||||||
f"directly instead:\n"
|
|
||||||
f'`read_file("{skills_dir}/skill-name/SKILL.md")`\n\n'
|
|
||||||
f"The superpowers skills directory is: `{skills_dir}`\n\n"
|
|
||||||
f"{tool_mapping}\n"
|
|
||||||
f"</EXTREMELY_IMPORTANT>"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def register(ctx):
|
|
||||||
skills_dir = _skills_dir()
|
|
||||||
bootstrap = _build_bootstrap(skills_dir)
|
|
||||||
|
|
||||||
# Register every stock skill with Hermes' native loader so skill_view can
|
|
||||||
# load them on demand. Standard markdown; no conversion (plugin guide).
|
|
||||||
# register_skill requires a pathlib.Path — a str raises AttributeError and
|
|
||||||
# hermes silently disables the whole plugin (verified 2026-07-23).
|
|
||||||
for name in sorted(os.listdir(skills_dir)):
|
|
||||||
skill_md = os.path.join(skills_dir, name, "SKILL.md")
|
|
||||||
if os.path.isfile(skill_md):
|
|
||||||
ctx.register_skill(name, Path(skill_md))
|
|
||||||
|
|
||||||
# pre_llm_call returning {"context": ...} is the documented injection path
|
|
||||||
# (on_session_start return values are ignored, and ctx.inject_message
|
|
||||||
# refuses from that hook — verified empirically 2026-07-23). The context is
|
|
||||||
# appended to the first turn's user message.
|
|
||||||
def pre_llm_call(
|
|
||||||
session_id=None,
|
|
||||||
user_message=None,
|
|
||||||
conversation_history=None,
|
|
||||||
is_first_turn=None,
|
|
||||||
model=None,
|
|
||||||
platform=None,
|
|
||||||
**kwargs,
|
|
||||||
):
|
|
||||||
if is_first_turn:
|
|
||||||
return {"context": bootstrap}
|
|
||||||
return None
|
|
||||||
|
|
||||||
ctx.register_hook("pre_llm_call", pre_llm_call)
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
name: superpowers
|
|
||||||
version: 6.2.0
|
|
||||||
description: Superpowers skills and workflow bootstrap for Hermes Agent
|
|
||||||
author: obra
|
|
||||||
provides_hooks:
|
|
||||||
- pre_llm_call
|
|
||||||
14
README.md
14
README.md
@@ -30,7 +30,7 @@ Superpowers is a complete software development methodology for your coding agent
|
|||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
Give your agent Superpowers: [Claude Code](#claude-code), [Antigravity](#antigravity), [Codex App](#codex-app), [Codex CLI](#codex-cli), [Cursor](#cursor), [Factory Droid](#factory-droid), [Gemini CLI](#gemini-cli), [GitHub Copilot CLI](#github-copilot-cli), [Hermes Agent](#hermes-agent), [Kimi Code](#kimi-code), [OpenCode](#opencode), [Pi](#pi).
|
Give your agent Superpowers: [Claude Code](#claude-code), [Antigravity](#antigravity), [Codex App](#codex-app), [Codex CLI](#codex-cli), [Cursor](#cursor), [Factory Droid](#factory-droid), [Gemini CLI](#gemini-cli), [GitHub Copilot CLI](#github-copilot-cli), [Kimi Code](#kimi-code), [OpenCode](#opencode), [Pi](#pi).
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
@@ -218,18 +218,6 @@ pi -e /path/to/superpowers
|
|||||||
|
|
||||||
The Pi package loads the Superpowers skills and a small extension that injects the `using-superpowers` bootstrap at session startup and again after compaction. Pi has native skills, so no compatibility `Skill` tool is required. Subagent and task-list tools remain optional Pi companion packages.
|
The Pi package loads the Superpowers skills and a small extension that injects the `using-superpowers` bootstrap at session startup and again after compaction. Pi has native skills, so no compatibility `Skill` tool is required. Subagent and task-list tools remain optional Pi companion packages.
|
||||||
|
|
||||||
### Hermes Agent
|
|
||||||
|
|
||||||
Install Superpowers as a Hermes plugin from this repository:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
hermes plugins install obra/superpowers --enable
|
|
||||||
```
|
|
||||||
|
|
||||||
Restart any active Hermes sessions after installing. Note: Hermes has no
|
|
||||||
post-compaction hook, so a very long session that compacts over its first
|
|
||||||
turn loses the bootstrap — start a fresh session if skills stop triggering.
|
|
||||||
|
|
||||||
## The Basic Workflow
|
## The Basic Workflow
|
||||||
|
|
||||||
1. **brainstorming** - Activates before writing code. Refines rough ideas through questions, explores alternatives, presents design in sections for validation. Saves design document.
|
1. **brainstorming** - Activates before writing code. Refines rough ideas through questions, explores alternatives, presents design in sections for validation. Saves design document.
|
||||||
|
|||||||
@@ -745,8 +745,10 @@ single file that's valid as both a Windows batch script and a Unix shell script.
|
|||||||
On Windows, `cmd.exe` runs the batch portion, which locates `bash` (Git for
|
On Windows, `cmd.exe` runs the batch portion, which locates `bash` (Git for
|
||||||
Windows, then `bash` on PATH) and runs the named hook script; if no bash is
|
Windows, then `bash` on PATH) and runs the named hook script; if no bash is
|
||||||
found it exits cleanly so the harness still works, just without injection. On
|
found it exits cleanly so the harness still works, just without injection. On
|
||||||
Unix, the leading `:` makes the batch block a no-op and the shell runs the
|
Unix, the shell executes the leading `:;` lines and `exec`s the hook script
|
||||||
script directly.
|
before reaching the batch block (cmd.exe skips those lines as labels).
|
||||||
|
Heredocs are banned throughout hooks/ — see issue #571 and the fence test
|
||||||
|
`tests/hooks/test-no-heredocs-in-hooks.sh`.
|
||||||
|
|
||||||
Two rules this enforces, which you must respect:
|
Two rules this enforces, which you must respect:
|
||||||
|
|
||||||
|
|||||||
@@ -65,9 +65,9 @@ The path is quoted because `${CLAUDE_PLUGIN_ROOT}` may contain spaces.
|
|||||||
|
|
||||||
## How `run-hook.cmd` Works at a High Level
|
## How `run-hook.cmd` Works at a High Level
|
||||||
|
|
||||||
`run-hook.cmd` is a polyglot script: Windows treats the first block as batch
|
`run-hook.cmd` is a polyglot script: its first lines start with `:;`, which
|
||||||
commands, while Unix shells treat that block as a no-op heredoc and continue
|
cmd.exe skips as labels and Unix shells execute — the shell execs the hook
|
||||||
after it.
|
before ever reaching the batch block that Windows runs.
|
||||||
|
|
||||||
Do not copy an implementation from this document. Read `hooks/run-hook.cmd`
|
Do not copy an implementation from this document. Read `hooks/run-hook.cmd`
|
||||||
directly when changing the dispatcher, and run `tests/hooks/test-session-start.sh`
|
directly when changing the dispatcher, and run `tests/hooks/test-session-start.sh`
|
||||||
@@ -89,10 +89,14 @@ afterward.
|
|||||||
|
|
||||||
### How it works on Unix (bash/sh)
|
### How it works on Unix (bash/sh)
|
||||||
|
|
||||||
1. `: << 'CMDBLOCK'` opens a heredoc on a no-op command.
|
1. Each leading `:;` line is a no-op label to cmd.exe but real commands to a
|
||||||
2. The entire CMD batch block is consumed by the heredoc and ignored.
|
POSIX shell.
|
||||||
3. After `CMDBLOCK`, bash resolves the script directory and `exec`s the named
|
2. The shell checks bash exists (silent exit 0 if not), resolves the script
|
||||||
extensionless script directly.
|
directory CDPATH-proof, and `exec`s the named extensionless script — it
|
||||||
|
never reads the batch block at all.
|
||||||
|
3. Heredocs are banned in this file and all hooks/ executables (issue #571:
|
||||||
|
bash >= 5.1 pre-fork pipe writes deadlock on macOS under pipe pressure);
|
||||||
|
`tests/hooks/test-no-heredocs-in-hooks.sh` enforces the ban.
|
||||||
|
|
||||||
### Key design decisions
|
### Key design decisions
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,20 @@
|
|||||||
: << 'CMDBLOCK'
|
:; command -v bash >/dev/null 2>&1 || exit 0
|
||||||
|
:; [ $# -ge 1 ] || exit 0
|
||||||
|
:; SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||||
|
:; SCRIPT_NAME="$1"; shift; exec bash "${SCRIPT_DIR}/${SCRIPT_NAME}" "$@"
|
||||||
@echo off
|
@echo off
|
||||||
REM Cross-platform polyglot wrapper for hook scripts.
|
REM Cross-platform polyglot wrapper for hook scripts.
|
||||||
REM On Windows: cmd.exe runs the batch portion, which finds and calls bash.
|
REM On Unix: POSIX shells execute the ":;" lines above and exec away before
|
||||||
REM On Unix: the shell interprets this as a script (: is a no-op in bash).
|
REM reaching this batch block (":" is a no-op; cmd.exe treats lines starting
|
||||||
|
REM with ":" as labels and skips them). If bash or the script name is
|
||||||
|
REM missing, the wrapper exits 0 silently - hooks are optional context, never
|
||||||
|
REM a session breaker.
|
||||||
|
REM On Windows: cmd.exe runs this batch portion, which finds and calls bash.
|
||||||
|
REM
|
||||||
|
REM Heredocs are banned in this file and every hooks/ executable: bash 5.1+
|
||||||
|
REM delivers them via a pre-fork pipe write that deadlocks on macOS under
|
||||||
|
REM pipe pressure (issue #571). tests/hooks/test-no-heredocs-in-hooks.sh is
|
||||||
|
REM the fence.
|
||||||
REM
|
REM
|
||||||
REM Hook scripts use extensionless filenames (e.g. "session-start" not
|
REM Hook scripts use extensionless filenames (e.g. "session-start" not
|
||||||
REM "session-start.sh") so Claude Code's Windows auto-detection -- which
|
REM "session-start.sh") so Claude Code's Windows auto-detection -- which
|
||||||
@@ -35,12 +47,4 @@ if %ERRORLEVEL% equ 0 (
|
|||||||
)
|
)
|
||||||
|
|
||||||
REM No bash found - exit silently rather than error
|
REM No bash found - exit silently rather than error
|
||||||
REM (plugin still works, just without SessionStart context injection)
|
|
||||||
exit /b 0
|
exit /b 0
|
||||||
CMDBLOCK
|
|
||||||
|
|
||||||
# Unix: run the named script directly
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
SCRIPT_NAME="$1"
|
|
||||||
shift
|
|
||||||
exec bash "${SCRIPT_DIR}/${SCRIPT_NAME}" "$@"
|
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ If your harness appears here, read its reference file for special instructions:
|
|||||||
- Codex: `references/codex-tools.md`
|
- Codex: `references/codex-tools.md`
|
||||||
- Pi: `references/pi-tools.md`
|
- Pi: `references/pi-tools.md`
|
||||||
- Antigravity: `references/antigravity-tools.md`
|
- Antigravity: `references/antigravity-tools.md`
|
||||||
- Hermes Agent: `references/hermes-tools.md`
|
|
||||||
|
|
||||||
## User Instructions
|
## User Instructions
|
||||||
|
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
# Hermes Agent Tool Mapping
|
|
||||||
|
|
||||||
Skills speak in actions ("dispatch a subagent", "create a todo", "read a file"). On Hermes Agent these resolve to the tools below.
|
|
||||||
|
|
||||||
## Tools
|
|
||||||
|
|
||||||
| Action skills request | Hermes tool |
|
|
||||||
|---|---|
|
|
||||||
| Read a file | `read_file` |
|
|
||||||
| Create a new file | `write_file` |
|
|
||||||
| Edit a file (targeted patch) | `patch` |
|
|
||||||
| Run a shell command | `terminal` |
|
|
||||||
| Search file contents | `search_files` |
|
|
||||||
| Find files by name | `terminal` with `find` |
|
|
||||||
| Fetch a URL / read a webpage | `web_extract(urls=[...])` |
|
|
||||||
| Search the web | `web_search(query=...)` |
|
|
||||||
| Dispatch a subagent | `delegate_task(goal=..., context=..., toolsets=[...], role="leaf")` |
|
|
||||||
| Task tracking | `todo` tool |
|
|
||||||
| Invoke a skill | `skill_view("skill-name")` |
|
|
||||||
|
|
||||||
## Instructions file
|
|
||||||
|
|
||||||
When a skill mentions "your instructions file," on Hermes Agent this is **`AGENTS.md`** in the project directory, or **`SOUL.md`** globally at `~/.hermes/SOUL.md`.
|
|
||||||
|
|
||||||
## Invoking a skill
|
|
||||||
|
|
||||||
Hermes Agent has a `skills` toolset with `skill_view` and `skills_list` tools.
|
|
||||||
To invoke a superpowers skill, use:
|
|
||||||
|
|
||||||
```
|
|
||||||
skill_view("brainstorming")
|
|
||||||
skill_view("test-driven-development")
|
|
||||||
```
|
|
||||||
|
|
||||||
If `skill_view` cannot find a superpowers skill (it may not appear in the catalog
|
|
||||||
until the plugin fully registers it), fall back to reading the SKILL.md directly:
|
|
||||||
|
|
||||||
```
|
|
||||||
read_file(path="~/.hermes/plugins/superpowers/skills/<skill-name>/SKILL.md")
|
|
||||||
```
|
|
||||||
|
|
||||||
This fallback is the same mechanism used by other harnesses without native skill loading.
|
|
||||||
|
|
||||||
## Subagent dispatch
|
|
||||||
|
|
||||||
Use `delegate_task` to spawn isolated subagents for parallel or sequential workstreams:
|
|
||||||
|
|
||||||
```
|
|
||||||
delegate_task(goal="...", context="...", toolsets=[...], role="leaf")
|
|
||||||
```
|
|
||||||
|
|
||||||
If `delegate_task` is unavailable, do the work inline rather than inventing tool calls.
|
|
||||||
|
|
||||||
## Task tracking
|
|
||||||
|
|
||||||
Use the `todo` tool for task tracking within a session. For multi-agent task boards, use `hermes kanban` CLI if available. Treat older `TodoWrite` references as the task-tracking action.
|
|
||||||
@@ -13,9 +13,9 @@
|
|||||||
* Requires: graphviz (dot) installed on system
|
* Requires: graphviz (dot) installed on system
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as fs from 'fs';
|
const fs = require('fs');
|
||||||
import * as path from 'path';
|
const path = require('path');
|
||||||
import { execFileSync } from 'child_process';
|
const { execSync } = require('child_process');
|
||||||
|
|
||||||
function extractDotBlocks(markdown) {
|
function extractDotBlocks(markdown) {
|
||||||
const blocks = [];
|
const blocks = [];
|
||||||
@@ -69,7 +69,7 @@ ${bodies.join('\n\n')}
|
|||||||
|
|
||||||
function renderToSvg(dotContent) {
|
function renderToSvg(dotContent) {
|
||||||
try {
|
try {
|
||||||
return execFileSync('dot', ['-Tsvg'], {
|
return execSync('dot -Tsvg', {
|
||||||
input: dotContent,
|
input: dotContent,
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
maxBuffer: 10 * 1024 * 1024
|
maxBuffer: 10 * 1024 * 1024
|
||||||
@@ -107,10 +107,9 @@ function main() {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if dot is available. Run the binary directly rather than probing
|
// Check if dot is available
|
||||||
// with `which`, which is not a command on Windows.
|
|
||||||
try {
|
try {
|
||||||
execFileSync('dot', ['-V'], { stdio: 'ignore' });
|
execSync('which dot', { encoding: 'utf-8' });
|
||||||
} catch {
|
} catch {
|
||||||
console.error('Error: graphviz (dot) not found. Install with:');
|
console.error('Error: graphviz (dot) not found. Install with:');
|
||||||
console.error(' brew install graphviz # macOS');
|
console.error(' brew install graphviz # macOS');
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_ctx():
|
|
||||||
ctx = MagicMock()
|
|
||||||
ctx._hooks = {}
|
|
||||||
ctx._skills = {}
|
|
||||||
|
|
||||||
def register_hook(event, fn):
|
|
||||||
ctx._hooks[event] = fn
|
|
||||||
|
|
||||||
def register_skill(name, path):
|
|
||||||
# Mimic hermes' real register_skill, which calls path.exists() and
|
|
||||||
# therefore breaks on a str (the bug that silently disabled the whole
|
|
||||||
# plugin, found 2026-07-23). Keeping that fidelity here means a
|
|
||||||
# regression to str paths fails these tests instead of failing
|
|
||||||
# silently inside hermes.
|
|
||||||
if not isinstance(path, Path):
|
|
||||||
raise AttributeError(
|
|
||||||
f"register_skill requires a pathlib.Path, got {type(path).__name__}"
|
|
||||||
)
|
|
||||||
ctx._skills[name] = path
|
|
||||||
|
|
||||||
ctx.register_hook.side_effect = register_hook
|
|
||||||
ctx.register_skill.side_effect = register_skill
|
|
||||||
return ctx
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import importlib
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.abspath(
|
|
||||||
os.path.join(os.path.dirname(__file__), "../../.hermes-plugin")
|
|
||||||
))
|
|
||||||
|
|
||||||
BOOTSTRAP_MARKER = "superpowers:using-superpowers bootstrap for hermes"
|
|
||||||
|
|
||||||
# Hermes spills injected context over 10,000 chars to a file, which breaks
|
|
||||||
# inline injection semantics. The bootstrap must stay under it with margin.
|
|
||||||
HERMES_CONTEXT_SPILL_LIMIT = 10_000
|
|
||||||
|
|
||||||
|
|
||||||
def _load():
|
|
||||||
if "__init__" in sys.modules:
|
|
||||||
del sys.modules["__init__"]
|
|
||||||
return importlib.import_module("__init__")
|
|
||||||
|
|
||||||
|
|
||||||
def _bootstrap():
|
|
||||||
m = _load()
|
|
||||||
return m._build_bootstrap(m._skills_dir())
|
|
||||||
|
|
||||||
|
|
||||||
class TestStripFrontmatter:
|
|
||||||
def test_strips_yaml_block(self):
|
|
||||||
m = _load()
|
|
||||||
content = "---\nname: foo\ndescription: bar\n---\n# Body\nContent here"
|
|
||||||
assert m._strip_frontmatter(content) == "# Body\nContent here"
|
|
||||||
|
|
||||||
def test_no_frontmatter_returns_trimmed_content(self):
|
|
||||||
m = _load()
|
|
||||||
content = "# No frontmatter\nJust content"
|
|
||||||
assert m._strip_frontmatter(content) == "# No frontmatter\nJust content"
|
|
||||||
|
|
||||||
def test_strips_surrounding_whitespace_from_body(self):
|
|
||||||
m = _load()
|
|
||||||
content = "---\nname: foo\n---\n\n\n# Body\n\n"
|
|
||||||
assert m._strip_frontmatter(content) == "# Body"
|
|
||||||
|
|
||||||
|
|
||||||
class TestSkillsDirResolution:
|
|
||||||
def test_repo_layout_resolves(self):
|
|
||||||
# The repo checkout IS the git-clone layout: .hermes-plugin/ and
|
|
||||||
# skills/ are siblings, so resolution must succeed from here.
|
|
||||||
m = _load()
|
|
||||||
skills = m._skills_dir()
|
|
||||||
assert os.path.isfile(
|
|
||||||
os.path.join(skills, "using-superpowers", "SKILL.md")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestBootstrapContent:
|
|
||||||
def test_marker_and_wrapper(self):
|
|
||||||
content = _bootstrap()
|
|
||||||
assert BOOTSTRAP_MARKER in content
|
|
||||||
assert content.startswith("<EXTREMELY_IMPORTANT>")
|
|
||||||
assert content.rstrip().endswith("</EXTREMELY_IMPORTANT>")
|
|
||||||
|
|
||||||
def test_contains_using_superpowers_body(self):
|
|
||||||
content = _bootstrap()
|
|
||||||
# A distinctive line from the skill body proves the real SKILL.md was
|
|
||||||
# embedded, not a stub.
|
|
||||||
assert "You have superpowers" in content
|
|
||||||
assert "## The Rule" in content
|
|
||||||
|
|
||||||
def test_frontmatter_stripped(self):
|
|
||||||
content = _bootstrap()
|
|
||||||
assert "---\nname:" not in content
|
|
||||||
|
|
||||||
def test_tool_mapping_sourced_from_reference_file(self):
|
|
||||||
m = _load()
|
|
||||||
content = _bootstrap()
|
|
||||||
ref = os.path.join(
|
|
||||||
m._skills_dir(), "using-superpowers", "references", "hermes-tools.md"
|
|
||||||
)
|
|
||||||
with open(ref, encoding="utf-8") as f:
|
|
||||||
ref_text = f.read().strip()
|
|
||||||
# The mapping is included verbatim from the reference file — the
|
|
||||||
# single source, not a drift-prone inline copy.
|
|
||||||
assert ref_text in content
|
|
||||||
assert "read_file" in content
|
|
||||||
|
|
||||||
def test_skill_view_guidance_present(self):
|
|
||||||
content = _bootstrap()
|
|
||||||
assert 'skill_view("superpowers:brainstorming")' in content
|
|
||||||
|
|
||||||
def test_under_hermes_context_spill_limit(self):
|
|
||||||
content = _bootstrap()
|
|
||||||
assert len(content) < HERMES_CONTEXT_SPILL_LIMIT, (
|
|
||||||
f"bootstrap is {len(content)} chars; hermes spills injected "
|
|
||||||
f"context over {HERMES_CONTEXT_SPILL_LIMIT} to a file, which "
|
|
||||||
"breaks inline injection"
|
|
||||||
)
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
import importlib
|
|
||||||
import importlib.util
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
# Point at the plugin directory
|
|
||||||
_PLUGIN_DIR = os.path.abspath(
|
|
||||||
os.path.join(os.path.dirname(__file__), "../../.hermes-plugin")
|
|
||||||
)
|
|
||||||
sys.path.insert(0, _PLUGIN_DIR)
|
|
||||||
|
|
||||||
BOOTSTRAP_MARKER = "superpowers:using-superpowers bootstrap for hermes"
|
|
||||||
|
|
||||||
|
|
||||||
def _load_plugin():
|
|
||||||
"""Re-import plugin module fresh."""
|
|
||||||
if "__init__" in sys.modules:
|
|
||||||
del sys.modules["__init__"]
|
|
||||||
return importlib.import_module("__init__")
|
|
||||||
|
|
||||||
|
|
||||||
def _fire_pre_llm(ctx, **kwargs):
|
|
||||||
hook = ctx._hooks["pre_llm_call"]
|
|
||||||
defaults = {
|
|
||||||
"session_id": "s1",
|
|
||||||
"user_message": "hi",
|
|
||||||
"conversation_history": [],
|
|
||||||
"is_first_turn": False,
|
|
||||||
"model": "test-model",
|
|
||||||
"platform": "cli",
|
|
||||||
}
|
|
||||||
defaults.update(kwargs)
|
|
||||||
return hook(**defaults)
|
|
||||||
|
|
||||||
|
|
||||||
class TestPluginRegistration:
|
|
||||||
def test_register_attaches_only_pre_llm_call_hook(self, mock_ctx):
|
|
||||||
plugin = _load_plugin()
|
|
||||||
plugin.register(mock_ctx)
|
|
||||||
assert list(mock_ctx._hooks.keys()) == ["pre_llm_call"]
|
|
||||||
|
|
||||||
def test_register_registers_every_stock_skill_as_path(self, mock_ctx):
|
|
||||||
plugin = _load_plugin()
|
|
||||||
plugin.register(mock_ctx)
|
|
||||||
# The conftest mock raises on non-Path (mirroring hermes' real
|
|
||||||
# register_skill), so reaching these asserts proves every
|
|
||||||
# registration passed a pathlib.Path.
|
|
||||||
assert "using-superpowers" in mock_ctx._skills
|
|
||||||
assert "brainstorming" in mock_ctx._skills
|
|
||||||
for name, path in mock_ctx._skills.items():
|
|
||||||
assert isinstance(path, Path)
|
|
||||||
assert path.name == "SKILL.md"
|
|
||||||
assert path.parent.name == name
|
|
||||||
assert path.is_file()
|
|
||||||
|
|
||||||
def test_registered_skills_match_skill_directories(self, mock_ctx):
|
|
||||||
plugin = _load_plugin()
|
|
||||||
plugin.register(mock_ctx)
|
|
||||||
skills_root = plugin._skills_dir()
|
|
||||||
expected = {
|
|
||||||
entry
|
|
||||||
for entry in os.listdir(skills_root)
|
|
||||||
if os.path.isfile(os.path.join(skills_root, entry, "SKILL.md"))
|
|
||||||
}
|
|
||||||
assert set(mock_ctx._skills.keys()) == expected
|
|
||||||
|
|
||||||
|
|
||||||
class TestBootstrapInjection:
|
|
||||||
def test_first_turn_returns_bootstrap_context(self, mock_ctx):
|
|
||||||
plugin = _load_plugin()
|
|
||||||
plugin.register(mock_ctx)
|
|
||||||
result = _fire_pre_llm(mock_ctx, is_first_turn=True)
|
|
||||||
assert isinstance(result, dict)
|
|
||||||
content = result["context"]
|
|
||||||
assert BOOTSTRAP_MARKER in content
|
|
||||||
assert content.startswith("<EXTREMELY_IMPORTANT>")
|
|
||||||
assert content.rstrip().endswith("</EXTREMELY_IMPORTANT>")
|
|
||||||
|
|
||||||
def test_later_turns_return_none(self, mock_ctx):
|
|
||||||
plugin = _load_plugin()
|
|
||||||
plugin.register(mock_ctx)
|
|
||||||
assert _fire_pre_llm(mock_ctx, is_first_turn=False) is None
|
|
||||||
assert _fire_pre_llm(mock_ctx, is_first_turn=None) is None
|
|
||||||
|
|
||||||
def test_hook_tolerates_future_kwargs(self, mock_ctx):
|
|
||||||
plugin = _load_plugin()
|
|
||||||
plugin.register(mock_ctx)
|
|
||||||
result = _fire_pre_llm(
|
|
||||||
mock_ctx, is_first_turn=True, telemetry_schema_version=3
|
|
||||||
)
|
|
||||||
assert BOOTSTRAP_MARKER in result["context"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestLayoutResolution:
|
|
||||||
def _stage(self, tmp_path, layout):
|
|
||||||
"""Copy the plugin module + a minimal skills tree in the given layout."""
|
|
||||||
src_skills = Path(_PLUGIN_DIR).parent / "skills"
|
|
||||||
if layout == "clone":
|
|
||||||
plugdir = tmp_path / "superpowers" / ".hermes-plugin"
|
|
||||||
else: # flat: module at the plugin dir root, skills nested inside it
|
|
||||||
plugdir = tmp_path / "superpowers"
|
|
||||||
skills = tmp_path / "superpowers" / "skills"
|
|
||||||
plugdir.mkdir(parents=True, exist_ok=True)
|
|
||||||
shutil.copy(Path(_PLUGIN_DIR) / "__init__.py", plugdir / "__init__.py")
|
|
||||||
for skill in ("using-superpowers", "brainstorming"):
|
|
||||||
shutil.copytree(src_skills / skill, skills / skill)
|
|
||||||
return plugdir
|
|
||||||
|
|
||||||
def _load_from(self, plugdir):
|
|
||||||
spec = importlib.util.spec_from_file_location(
|
|
||||||
f"hermes_plugin_test_{plugdir.parent.name}_{plugdir.name}",
|
|
||||||
plugdir / "__init__.py",
|
|
||||||
)
|
|
||||||
mod = importlib.util.module_from_spec(spec)
|
|
||||||
spec.loader.exec_module(mod)
|
|
||||||
return mod
|
|
||||||
|
|
||||||
def test_clone_layout_resolves_sibling_skills(self, tmp_path, mock_ctx):
|
|
||||||
# git-clone install: .hermes-plugin/ and skills/ are siblings.
|
|
||||||
plugdir = self._stage(tmp_path, "clone")
|
|
||||||
mod = self._load_from(plugdir)
|
|
||||||
mod.register(mock_ctx)
|
|
||||||
assert "using-superpowers" in mock_ctx._skills
|
|
||||||
|
|
||||||
def test_flat_layout_resolves_nested_skills(self, tmp_path, mock_ctx):
|
|
||||||
# flattened install: module at the plugin dir root, skills/ inside it.
|
|
||||||
plugdir = self._stage(tmp_path, "flat")
|
|
||||||
mod = self._load_from(plugdir)
|
|
||||||
mod.register(mock_ctx)
|
|
||||||
assert "using-superpowers" in mock_ctx._skills
|
|
||||||
|
|
||||||
def test_missing_skills_raises_loudly(self, tmp_path, mock_ctx):
|
|
||||||
plugdir = tmp_path / "superpowers"
|
|
||||||
plugdir.mkdir(parents=True)
|
|
||||||
shutil.copy(Path(_PLUGIN_DIR) / "__init__.py", plugdir / "__init__.py")
|
|
||||||
mod = self._load_from(plugdir)
|
|
||||||
with pytest.raises(RuntimeError, match="cannot find the skills"):
|
|
||||||
mod.register(mock_ctx)
|
|
||||||
30
tests/hooks/test-no-heredocs-in-hooks.sh
Executable file
30
tests/hooks/test-no-heredocs-in-hooks.sh
Executable file
@@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Heredocs are banned from the hook delivery chain: bash >= 5.1 delivers
|
||||||
|
# them through a pre-fork pipe write that deadlocks on macOS under pipe
|
||||||
|
# pressure (issue #571; the #571 class). This fence fails the moment one
|
||||||
|
# returns. The operator is spelled out of a variable so this file does not
|
||||||
|
# trip its own check.
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
|
||||||
|
OP='<''<'
|
||||||
|
FAILURES=0
|
||||||
|
for f in "$REPO_ROOT"/hooks/*; do
|
||||||
|
case "$f" in *.json) continue;; esac
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
if hits="$(grep -nE "$OP" "$f")"; then
|
||||||
|
echo " [FAIL] heredoc operator in ${f#"$REPO_ROOT"/}:"
|
||||||
|
printf '%s\n' "$hits" | sed 's/^/ /'
|
||||||
|
FAILURES=$((FAILURES + 1))
|
||||||
|
else
|
||||||
|
echo " [PASS] ${f#"$REPO_ROOT"/} is heredoc-free"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$FAILURES" -gt 0 ]; then
|
||||||
|
echo "STATUS: FAILED ($FAILURES file(s))"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "STATUS: PASSED"
|
||||||
@@ -184,6 +184,15 @@ assert_command_output \
|
|||||||
CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
|
CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
|
||||||
bash "$WRAPPER_UNDER_TEST" session-start
|
bash "$WRAPPER_UNDER_TEST" session-start
|
||||||
|
|
||||||
|
# With no bash available, the wrapper must fail open: silent, exit 0.
|
||||||
|
if output="$(env -i PATH=/nonexistent /bin/sh "$WRAPPER_UNDER_TEST" session-start 2>&1)" \
|
||||||
|
&& [ -z "$output" ]; then
|
||||||
|
pass "wrapper with no bash on PATH is silent and exits 0"
|
||||||
|
else
|
||||||
|
fail "wrapper with no bash on PATH is silent and exits 0"
|
||||||
|
printf '%s\n' "$output" | head -3 | sed 's/^/ /'
|
||||||
|
fi
|
||||||
|
|
||||||
cursor_home="$(make_home cursor)"
|
cursor_home="$(make_home cursor)"
|
||||||
assert_command_output \
|
assert_command_output \
|
||||||
"Cursor emits top-level additional_context only" \
|
"Cursor emits top-level additional_context only" \
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -u
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|
||||||
SCRIPT_UNDER_TEST="$REPO_ROOT/skills/writing-skills/render-graphs.js"
|
|
||||||
NODE_BIN="$(command -v node)"
|
|
||||||
|
|
||||||
PASSES=0
|
|
||||||
FAILURES=0
|
|
||||||
TEST_ROOT="$(mktemp -d)"
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
rm -rf "$TEST_ROOT"
|
|
||||||
}
|
|
||||||
trap cleanup EXIT
|
|
||||||
|
|
||||||
pass() {
|
|
||||||
echo " [PASS] $1"
|
|
||||||
PASSES=$((PASSES + 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
fail() {
|
|
||||||
echo " [FAIL] $1"
|
|
||||||
FAILURES=$((FAILURES + 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_contains() {
|
|
||||||
local haystack="$1"
|
|
||||||
local needle="$2"
|
|
||||||
local description="$3"
|
|
||||||
|
|
||||||
if printf '%s' "$haystack" | grep -Fq -- "$needle"; then
|
|
||||||
pass "$description"
|
|
||||||
else
|
|
||||||
fail "$description"
|
|
||||||
echo " expected to find: $needle"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_not_contains() {
|
|
||||||
local haystack="$1"
|
|
||||||
local needle="$2"
|
|
||||||
local description="$3"
|
|
||||||
|
|
||||||
if printf '%s' "$haystack" | grep -Fq -- "$needle"; then
|
|
||||||
fail "$description"
|
|
||||||
echo " did not expect to find: $needle"
|
|
||||||
else
|
|
||||||
pass "$description"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
fixture="$TEST_ROOT/fixture-skill"
|
|
||||||
mkdir -p "$fixture" "$TEST_ROOT/empty-path"
|
|
||||||
cat >"$fixture/SKILL.md" <<'EOF'
|
|
||||||
---
|
|
||||||
name: fixture-skill
|
|
||||||
---
|
|
||||||
|
|
||||||
# Fixture Skill
|
|
||||||
|
|
||||||
```dot
|
|
||||||
digraph fixture_graph {
|
|
||||||
start -> end;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
EOF
|
|
||||||
|
|
||||||
echo "Writing-skills render-graphs tests"
|
|
||||||
|
|
||||||
missing_dot_output="$(PATH="$TEST_ROOT/empty-path" "$NODE_BIN" "$SCRIPT_UNDER_TEST" "$fixture" 2>&1)"
|
|
||||||
missing_dot_status=$?
|
|
||||||
|
|
||||||
if [[ "$missing_dot_status" -ne 0 ]]; then
|
|
||||||
pass "missing Graphviz exits non-zero"
|
|
||||||
else
|
|
||||||
fail "missing Graphviz exits non-zero"
|
|
||||||
fi
|
|
||||||
assert_contains "$missing_dot_output" "Error: graphviz (dot) not found." "missing Graphviz reports install guidance"
|
|
||||||
assert_not_contains "$missing_dot_output" "ReferenceError: require is not defined" "script runs as an ES module"
|
|
||||||
|
|
||||||
render_output="$("$NODE_BIN" "$SCRIPT_UNDER_TEST" "$fixture" 2>&1)"
|
|
||||||
render_status=$?
|
|
||||||
|
|
||||||
if [[ "$render_status" -eq 0 ]]; then
|
|
||||||
pass "fixture diagram renders"
|
|
||||||
else
|
|
||||||
fail "fixture diagram renders"
|
|
||||||
printf '%s\n' "$render_output"
|
|
||||||
fi
|
|
||||||
|
|
||||||
assert_contains "$render_output" "Found 1 diagram(s)" "reports discovered diagram"
|
|
||||||
assert_contains "$render_output" "Rendered: fixture_graph.svg" "reports rendered SVG"
|
|
||||||
|
|
||||||
if [[ -f "$fixture/diagrams/fixture_graph.svg" ]]; then
|
|
||||||
pass "writes SVG output"
|
|
||||||
else
|
|
||||||
fail "writes SVG output"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -f "$fixture/diagrams/fixture_graph.svg" ]] && grep -Fq "<svg" "$fixture/diagrams/fixture_graph.svg"; then
|
|
||||||
pass "SVG output has SVG markup"
|
|
||||||
else
|
|
||||||
fail "SVG output has SVG markup"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "Results: $PASSES passed, $FAILURES failed"
|
|
||||||
|
|
||||||
if [[ "$FAILURES" -gt 0 ]]; then
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
Reference in New Issue
Block a user