18 Commits

Author SHA1 Message Date
Affaan Mustafa
4a9918db00 fix: flush bash hook output safely 2026-04-14 21:20:01 -07:00
Affaan Mustafa
a8bb5979a5 fix: preserve commit-quality blocking in bash dispatcher 2026-04-14 21:17:17 -07:00
Affaan Mustafa
1c45152c6d test: align integration hooks with bash dispatcher 2026-04-14 21:10:54 -07:00
Affaan Mustafa
5bfb3cc563 fix: consolidate bash hooks to avoid fork storms 2026-04-14 20:59:39 -07:00
Affaan Mustafa
5427c27930 Merge pull request #1445 from affaan-m/fix/plugin-installed-hook-root-resolution
fix: resolve plugin-installed hook root on marketplace installs
2026-04-14 20:43:40 -07:00
Affaan Mustafa
8da668f1ac Merge pull request #1439 from affaan-m/fix/urgent-install-and-name
fix: unblock urgent install and gateguard regressions
2026-04-14 20:36:06 -07:00
Affaan Mustafa
1b7c5789fc fix: bootstrap plugin-installed hook commands safely 2026-04-14 20:24:21 -07:00
Affaan Mustafa
cdeb837838 Merge origin/main into fix/urgent-install-and-name 2026-04-14 20:23:54 -07:00
Affaan Mustafa
cca163c776 Merge pull request #1440 from affaan-m/fix/dashboard-terminal-safety
fix(dashboard): harden terminal launch and maximize behavior
2026-04-14 20:21:51 -07:00
Affaan Mustafa
c54b44edf3 test: fix harness audit env fallback 2026-04-14 20:03:57 -07:00
Affaan Mustafa
2691cfc0f1 fix: restore dashboard branch ci baseline 2026-04-14 19:54:28 -07:00
Affaan Mustafa
b2c4b7f51c Merge remote-tracking branch 'origin/main' into fix/urgent-install-and-name 2026-04-14 19:50:35 -07:00
Affaan Mustafa
c924290b5b fix: restore dashboard branch CI baseline 2026-04-14 19:46:00 -07:00
Affaan Mustafa
e46deb93c8 fix: harden dashboard terminal launch helpers 2026-04-14 19:44:32 -07:00
Affaan Mustafa
8776c4f8f3 fix: harden urgent install and gateguard patch 2026-04-14 19:44:08 -07:00
Affaan Mustafa
e5225db006 docs: sync catalog counts on urgent fix branch 2026-04-14 19:31:23 -07:00
Affaan Mustafa
3be24a5704 fix: restore urgent PR CI health 2026-04-14 19:26:24 -07:00
Affaan Mustafa
76b6e22b4d fix: unblock urgent install and gateguard regressions 2026-04-14 19:23:07 -07:00
43 changed files with 1841 additions and 398 deletions

View File

@@ -1,6 +1,6 @@
# Everything Claude Code (ECC) — Agent Instructions # Everything Claude Code (ECC) — Agent Instructions
This is a **production-ready AI coding plugin** providing 47 specialized agents, 181 skills, 79 commands, and automated hook workflows for software development. This is a **production-ready AI coding plugin** providing 48 specialized agents, 183 skills, 79 commands, and automated hook workflows for software development.
**Version:** 1.10.0 **Version:** 1.10.0
@@ -145,8 +145,8 @@ Troubleshoot failures: check test isolation → verify mocks → fix implementat
## Project Structure ## Project Structure
``` ```
agents/ — 47 specialized subagents agents/ — 48 specialized subagents
skills/ — 181 workflow skills and domain knowledge skills/ — 183 workflow skills and domain knowledge
commands/ — 79 slash commands commands/ — 79 slash commands
hooks/ — Trigger-based automations hooks/ — Trigger-based automations
rules/ — Always-follow guidelines (common + per-language) rules/ — Always-follow guidelines (common + per-language)

View File

@@ -174,9 +174,11 @@ Get up and running in under 2 minutes:
/plugin marketplace add https://github.com/affaan-m/everything-claude-code /plugin marketplace add https://github.com/affaan-m/everything-claude-code
# Install plugin # Install plugin
/plugin install ecc@ecc /plugin install everything-claude-code
``` ```
> Install-name clarification: older posts may still show `ecc@ecc`. That shorthand is deprecated. Anthropic marketplace/plugin installs are keyed by a canonical plugin identifier, so ECC standardized on `everything-claude-code@everything-claude-code` to keep the listing name, install path, `/plugin list`, and repo docs aligned instead of maintaining two different public names for the same plugin.
### Step 2: Install Rules (Required) ### Step 2: Install Rules (Required)
> WARNING: **Important:** Claude Code plugins cannot distribute `rules` automatically. Install them manually: > WARNING: **Important:** Claude Code plugins cannot distribute `rules` automatically. Install them manually:
@@ -236,10 +238,10 @@ For manual install instructions see the README in the `rules/` folder. When copy
# /plan "Add user authentication" # /plan "Add user authentication"
# Check available commands # Check available commands
/plugin list ecc@ecc /plugin list everything-claude-code@everything-claude-code
``` ```
**That's it!** You now have access to 47 agents, 181 skills, and 79 legacy command shims. **That's it!** You now have access to 48 agents, 183 skills, and 79 legacy command shims.
### Dashboard GUI ### Dashboard GUI
@@ -648,7 +650,7 @@ The easiest way to use this repo - install as a Claude Code plugin:
/plugin marketplace add https://github.com/affaan-m/everything-claude-code /plugin marketplace add https://github.com/affaan-m/everything-claude-code
# Install the plugin # Install the plugin
/plugin install ecc@ecc /plugin install everything-claude-code
``` ```
Or add directly to your `~/.claude/settings.json`: Or add directly to your `~/.claude/settings.json`:
@@ -664,7 +666,7 @@ Or add directly to your `~/.claude/settings.json`:
} }
}, },
"enabledPlugins": { "enabledPlugins": {
"ecc@ecc": true "everything-claude-code@everything-claude-code": true
} }
} }
``` ```
@@ -729,7 +731,7 @@ cp everything-claude-code/commands/*.md ~/.claude/commands/
#### Install hooks #### Install hooks
Do not copy the raw repo `hooks/hooks.json` into `~/.claude/settings.json` or `~/.claude/hooks/hooks.json`. That file is plugin/repo-oriented and still contains `${CLAUDE_PLUGIN_ROOT}` placeholders, so raw copying is not a supported manual install path. Do not copy the raw repo `hooks/hooks.json` into `~/.claude/settings.json` or `~/.claude/hooks/hooks.json`. That file is plugin/repo-oriented and is meant to be installed through the ECC installer or loaded as a plugin, so raw copying is not a supported manual install path.
Use the installer to install only the Claude hook runtime so command paths are rewritten correctly: Use the installer to install only the Claude hook runtime so command paths are rewritten correctly:
@@ -745,7 +747,7 @@ pwsh -File .\install.ps1 --target claude --modules hooks-runtime
That writes resolved hooks to `~/.claude/hooks/hooks.json` and leaves any existing `~/.claude/settings.json` untouched. That writes resolved hooks to `~/.claude/hooks/hooks.json` and leaves any existing `~/.claude/settings.json` untouched.
If you installed ECC via `/plugin install`, do not copy those hooks into `settings.json`. Claude Code v2.1+ already auto-loads plugin `hooks/hooks.json`, and duplicating them in `settings.json` causes duplicate execution and `${CLAUDE_PLUGIN_ROOT}` resolution failures. If you installed ECC via `/plugin install`, do not copy those hooks into `settings.json`. Claude Code v2.1+ already auto-loads plugin `hooks/hooks.json`, and duplicating them in `settings.json` causes duplicate execution and cross-platform hook conflicts.
Windows note: the Claude config directory is `%USERPROFILE%\\.claude`, not `~/claude`. Windows note: the Claude config directory is `%USERPROFILE%\\.claude`, not `~/claude`.
@@ -882,7 +884,7 @@ Slash forms below are shown because they are still the fastest familiar entrypoi
<summary><b>How do I check which agents/commands are installed?</b></summary> <summary><b>How do I check which agents/commands are installed?</b></summary>
```bash ```bash
/plugin list ecc@ecc /plugin list everything-claude-code@everything-claude-code
``` ```
This shows all available agents, commands, and skills from the plugin. This shows all available agents, commands, and skills from the plugin.
@@ -1205,9 +1207,9 @@ The configuration is automatically detected from `.opencode/opencode.json`.
| Feature | Claude Code | OpenCode | Status | | Feature | Claude Code | OpenCode | Status |
|---------|-------------|----------|--------| |---------|-------------|----------|--------|
| Agents | PASS: 47 agents | PASS: 12 agents | **Claude Code leads** | | Agents | PASS: 48 agents | PASS: 12 agents | **Claude Code leads** |
| Commands | PASS: 79 commands | PASS: 31 commands | **Claude Code leads** | | Commands | PASS: 79 commands | PASS: 31 commands | **Claude Code leads** |
| Skills | PASS: 181 skills | PASS: 37 skills | **Claude Code leads** | | Skills | PASS: 183 skills | PASS: 37 skills | **Claude Code leads** |
| Hooks | PASS: 8 event types | PASS: 11 events | **OpenCode has more!** | | Hooks | PASS: 8 event types | PASS: 11 events | **OpenCode has more!** |
| Rules | PASS: 29 rules | PASS: 13 instructions | **Claude Code leads** | | Rules | PASS: 29 rules | PASS: 13 instructions | **Claude Code leads** |
| MCP Servers | PASS: 14 servers | PASS: Full | **Full parity** | | MCP Servers | PASS: 14 servers | PASS: Full | **Full parity** |
@@ -1314,9 +1316,9 @@ ECC is the **first plugin to maximize every major AI coding tool**. Here's how e
| Feature | Claude Code | Cursor IDE | Codex CLI | OpenCode | | Feature | Claude Code | Cursor IDE | Codex CLI | OpenCode |
|---------|------------|------------|-----------|----------| |---------|------------|------------|-----------|----------|
| **Agents** | 47 | Shared (AGENTS.md) | Shared (AGENTS.md) | 12 | | **Agents** | 48 | Shared (AGENTS.md) | Shared (AGENTS.md) | 12 |
| **Commands** | 79 | Shared | Instruction-based | 31 | | **Commands** | 79 | Shared | Instruction-based | 31 |
| **Skills** | 181 | Shared | 10 (native format) | 37 | | **Skills** | 183 | Shared | 10 (native format) | 37 |
| **Hook Events** | 8 types | 15 types | None yet | 11 types | | **Hook Events** | 8 types | 15 types | None yet | 11 types |
| **Hook Scripts** | 20+ scripts | 16 scripts (DRY adapter) | N/A | Plugin hooks | | **Hook Scripts** | 20+ scripts | 16 scripts (DRY adapter) | N/A | Plugin hooks |
| **Rules** | 34 (common + lang) | 34 (YAML frontmatter) | Instruction-based | 13 instructions | | **Rules** | 34 (common + lang) | 34 (YAML frontmatter) | Instruction-based | 13 instructions |

View File

@@ -99,12 +99,14 @@
```bash ```bash
# 添加市场 # 添加市场
/plugin marketplace add affaan-m/everything-claude-code /plugin marketplace add https://github.com/affaan-m/everything-claude-code
# 安装插件 # 安装插件
/plugin install ecc@ecc /plugin install everything-claude-code
``` ```
> 安装名称说明:较早的帖子里可能还会出现 `ecc@ecc`。那个旧缩写现在已经废弃。Anthropic 的 marketplace/plugin 安装是按规范化插件标识符寻址的,因此 ECC 统一为 `everything-claude-code@everything-claude-code`,这样市场条目、安装命令、`/plugin list` 输出和仓库文档都使用同一个公开名称,不再出现两个名字指向同一插件的混乱。
### 第二步:安装规则(必需) ### 第二步:安装规则(必需)
> WARNING: **重要提示:** Claude Code 插件无法自动分发 `rules`,需要手动安装: > WARNING: **重要提示:** Claude Code 插件无法自动分发 `rules`,需要手动安装:
@@ -159,10 +161,10 @@ npx ecc-install typescript
# /plan "添加用户认证" # /plan "添加用户认证"
# 查看可用命令 # 查看可用命令
/plugin list ecc@ecc /plugin list everything-claude-code@everything-claude-code
``` ```
**完成!** 你现在可以使用 47 个代理、181 个技能和 79 个命令。 **完成!** 你现在可以使用 48 个代理、183 个技能和 79 个命令。
### multi-* 命令需要额外配置 ### multi-* 命令需要额外配置
@@ -543,10 +545,10 @@ Claude Code v2.1+ 会**按照约定自动加载**已安装插件中的 `hooks/ho
```bash ```bash
# 将此仓库添加为市场 # 将此仓库添加为市场
/plugin marketplace add affaan-m/everything-claude-code /plugin marketplace add https://github.com/affaan-m/everything-claude-code
# 安装插件 # 安装插件
/plugin install ecc@ecc /plugin install everything-claude-code
``` ```
或直接添加到你的 `~/.claude/settings.json` 或直接添加到你的 `~/.claude/settings.json`
@@ -562,7 +564,7 @@ Claude Code v2.1+ 会**按照约定自动加载**已安装插件中的 `hooks/ho
} }
}, },
"enabledPlugins": { "enabledPlugins": {
"ecc@ecc": true "everything-claude-code@everything-claude-code": true
} }
} }
``` ```

View File

@@ -1,7 +1,9 @@
--- ---
name: a11y-architect name: a11y-architect
description: Accessibility Architect specializing in WCAG 2.2 compliance for Web and Native platforms. Use PROACTIVELY when designing UI components, establishing design systems, or auditing code for inclusive user experiences. description: Accessibility Architect specializing in WCAG 2.2 compliance for Web and Native platforms. Use PROACTIVELY when designing UI components, establishing design systems, or auditing code for inclusive user experiences.
model: sonnet
tools: ["Read", "Write", "Edit", "Bash", "Grep", "Glob"] tools: ["Read", "Write", "Edit", "Bash", "Grep", "Glob"]
model: opus
--- ---
You are a Senior Accessibility Architect. Your goal is to ensure that every digital product is Perceivable, Operable, Understandable, and Robust (POUR) for all users, including those with visual, auditory, motor, or cognitive disabilities. You are a Senior Accessibility Architect. Your goal is to ensure that every digital product is Perceivable, Operable, Understandable, and Robust (POUR) for all users, including those with visual, auditory, motor, or cognitive disabilities.

View File

@@ -110,7 +110,7 @@
/plugin marketplace add https://github.com/affaan-m/everything-claude-code /plugin marketplace add https://github.com/affaan-m/everything-claude-code
# プラグインをインストール # プラグインをインストール
/plugin install ecc@ecc /plugin install everything-claude-code
``` ```
### ステップ2ルールをインストール必須 ### ステップ2ルールをインストール必須
@@ -140,7 +140,7 @@ cp -r everything-claude-code/rules/golang/* ~/.claude/rules/
# /plan "ユーザー認証を追加" # /plan "ユーザー認証を追加"
# 利用可能なコマンドを確認 # 利用可能なコマンドを確認
/plugin list ecc@ecc /plugin list everything-claude-code@everything-claude-code
``` ```
**完了です!** これで13のエージェント、43のスキル、31のコマンドにアクセスできます。 **完了です!** これで13のエージェント、43のスキル、31のコマンドにアクセスできます。
@@ -427,7 +427,7 @@ Duplicate hook file detected: ./hooks/hooks.json is already resolved to a loaded
/plugin marketplace add https://github.com/affaan-m/everything-claude-code /plugin marketplace add https://github.com/affaan-m/everything-claude-code
# プラグインをインストール # プラグインをインストール
/plugin install ecc@ecc /plugin install everything-claude-code
``` ```
または、`~/.claude/settings.json` に直接追加: または、`~/.claude/settings.json` に直接追加:
@@ -443,7 +443,7 @@ Duplicate hook file detected: ./hooks/hooks.json is already resolved to a loaded
} }
}, },
"enabledPlugins": { "enabledPlugins": {
"ecc@ecc": true "everything-claude-code@everything-claude-code": true
} }
} }
``` ```

View File

@@ -17,7 +17,7 @@ Everything Claude Code プロジェクトのインタラクティブなステッ
## 前提条件 ## 前提条件
このスキルは起動前に Claude Code からアクセス可能である必要があります。ブートストラップには2つの方法があります このスキルは起動前に Claude Code からアクセス可能である必要があります。ブートストラップには2つの方法があります
1. **プラグイン経由**: `/plugin install ecc@ecc` — プラグインがこのスキルを自動的にロードします 1. **プラグイン経由**: `/plugin install everything-claude-code` — プラグインがこのスキルを自動的にロードします
2. **手動**: このスキルのみを `~/.claude/skills/configure-ecc/SKILL.md` にコピーし、"configure ecc" と言って起動します 2. **手動**: このスキルのみを `~/.claude/skills/configure-ecc/SKILL.md` にコピーし、"configure ecc" と言って起動します
--- ---

View File

@@ -115,7 +115,7 @@
/plugin marketplace add https://github.com/affaan-m/everything-claude-code /plugin marketplace add https://github.com/affaan-m/everything-claude-code
# 플러그인 설치 # 플러그인 설치
/plugin install ecc@ecc /plugin install everything-claude-code
``` ```
### 2단계: 룰 설치 (필수) ### 2단계: 룰 설치 (필수)
@@ -147,7 +147,7 @@ cd everything-claude-code
# /plan "사용자 인증 추가" # /plan "사용자 인증 추가"
# 사용 가능한 커맨드 확인 # 사용 가능한 커맨드 확인
/plugin list ecc@ecc /plugin list everything-claude-code@everything-claude-code
``` ```
**끝!** 이제 16개 에이전트, 65개 스킬, 40개 커맨드를 사용할 수 있습니다. **끝!** 이제 16개 에이전트, 65개 스킬, 40개 커맨드를 사용할 수 있습니다.
@@ -359,7 +359,7 @@ Claude Code v2.1+는 설치된 플러그인의 `hooks/hooks.json`을 **자동으
/plugin marketplace add https://github.com/affaan-m/everything-claude-code /plugin marketplace add https://github.com/affaan-m/everything-claude-code
# 플러그인 설치 # 플러그인 설치
/plugin install ecc@ecc /plugin install everything-claude-code
``` ```
또는 `~/.claude/settings.json`에 직접 추가: 또는 `~/.claude/settings.json`에 직접 추가:
@@ -375,7 +375,7 @@ Claude Code v2.1+는 설치된 플러그인의 `hooks/hooks.json`을 **자동으
} }
}, },
"enabledPlugins": { "enabledPlugins": {
"ecc@ecc": true "everything-claude-code@everything-claude-code": true
} }
} }
``` ```
@@ -535,7 +535,7 @@ rules/
<summary><b>설치된 에이전트/커맨드 확인은 어떻게 하나요?</b></summary> <summary><b>설치된 에이전트/커맨드 확인은 어떻게 하나요?</b></summary>
```bash ```bash
/plugin list ecc@ecc /plugin list everything-claude-code@everything-claude-code
``` ```
플러그인에서 사용할 수 있는 모든 에이전트, 커맨드, 스킬을 보여줍니다. 플러그인에서 사용할 수 있는 모든 에이전트, 커맨드, 스킬을 보여줍니다.

View File

@@ -124,7 +124,7 @@ Comece em menos de 2 minutos:
/plugin marketplace add https://github.com/affaan-m/everything-claude-code /plugin marketplace add https://github.com/affaan-m/everything-claude-code
# Instalar plugin # Instalar plugin
/plugin install ecc@ecc /plugin install everything-claude-code
``` ```
### Passo 2: Instalar as Regras (Obrigatório) ### Passo 2: Instalar as Regras (Obrigatório)
@@ -167,7 +167,7 @@ npx ecc-install typescript
# /plan "Adicionar autenticação de usuário" # /plan "Adicionar autenticação de usuário"
# Verificar comandos disponíveis # Verificar comandos disponíveis
/plugin list ecc@ecc /plugin list everything-claude-code@everything-claude-code
``` ```
**Pronto!** Você agora tem acesso a 28 agentes, 116 skills e 59 comandos. **Pronto!** Você agora tem acesso a 28 agentes, 116 skills e 59 comandos.
@@ -313,7 +313,7 @@ claude --version
/plugin marketplace add https://github.com/affaan-m/everything-claude-code /plugin marketplace add https://github.com/affaan-m/everything-claude-code
# Instalar o plugin # Instalar o plugin
/plugin install ecc@ecc /plugin install everything-claude-code
``` ```
Ou adicione diretamente ao seu `~/.claude/settings.json`: Ou adicione diretamente ao seu `~/.claude/settings.json`:
@@ -329,7 +329,7 @@ Ou adicione diretamente ao seu `~/.claude/settings.json`:
} }
}, },
"enabledPlugins": { "enabledPlugins": {
"ecc@ecc": true "everything-claude-code@everything-claude-code": true
} }
} }
``` ```
@@ -452,7 +452,7 @@ Regras são diretrizes sempre seguidas, organizadas em `common/` (agnóstico à
<summary><b>Como verificar quais agentes/comandos estão instalados?</b></summary> <summary><b>Como verificar quais agentes/comandos estão instalados?</b></summary>
```bash ```bash
/plugin list ecc@ecc /plugin list everything-claude-code@everything-claude-code
``` ```
</details> </details>

View File

@@ -125,7 +125,7 @@ Bu repository yalnızca ham kodu içerir. Rehberler her şeyi açıklıyor.
/plugin marketplace add https://github.com/affaan-m/everything-claude-code /plugin marketplace add https://github.com/affaan-m/everything-claude-code
# Plugin'i kur # Plugin'i kur
/plugin install ecc@ecc /plugin install everything-claude-code
``` ```
### Adım 2: Rule'ları Kurun (Gerekli) ### Adım 2: Rule'ları Kurun (Gerekli)
@@ -170,7 +170,7 @@ Manuel kurulum talimatları için `rules/` klasöründeki README'ye bakın.
# /plan "Kullanıcı kimlik doğrulaması ekle" # /plan "Kullanıcı kimlik doğrulaması ekle"
# Mevcut command'ları kontrol edin # Mevcut command'ları kontrol edin
/plugin list ecc@ecc /plugin list everything-claude-code@everything-claude-code
``` ```
**Bu kadar!** Artık 28 agent, 116 skill ve 59 command'a erişiminiz var. **Bu kadar!** Artık 28 agent, 116 skill ve 59 command'a erişiminiz var.
@@ -352,7 +352,7 @@ Nereden başlayacağınızdan emin değil misiniz? Bu hızlı referansı kullan
<summary><b>Hangi agent/command'ların kurulu olduğunu nasıl kontrol ederim?</b></summary> <summary><b>Hangi agent/command'ların kurulu olduğunu nasıl kontrol ederim?</b></summary>
```bash ```bash
/plugin list ecc@ecc /plugin list everything-claude-code@everything-claude-code
``` ```
Bu, plugin'den mevcut tüm agent'ları, command'ları ve skill'leri gösterir. Bu, plugin'den mevcut tüm agent'ları, command'ları ve skill'leri gösterir.

View File

@@ -1,6 +1,6 @@
# Everything Claude Code (ECC) — 智能体指令 # Everything Claude Code (ECC) — 智能体指令
这是一个**生产就绪的 AI 编码插件**,提供 47 个专业代理、181 项技能、79 条命令以及自动化钩子工作流,用于软件开发。 这是一个**生产就绪的 AI 编码插件**,提供 48 个专业代理、183 项技能、79 条命令以及自动化钩子工作流,用于软件开发。
**版本:** 1.10.0 **版本:** 1.10.0
@@ -146,8 +146,8 @@
## 项目结构 ## 项目结构
``` ```
agents/ — 47 个专业子代理 agents/ — 48 个专业子代理
skills/ — 181 个工作流技能和领域知识 skills/ — 183 个工作流技能和领域知识
commands/ — 79 个斜杠命令 commands/ — 79 个斜杠命令
hooks/ — 基于触发的自动化 hooks/ — 基于触发的自动化
rules/ — 始终遵循的指导方针(通用 + 每种语言) rules/ — 始终遵循的指导方针(通用 + 每种语言)

View File

@@ -161,7 +161,7 @@
/plugin marketplace add https://github.com/affaan-m/everything-claude-code /plugin marketplace add https://github.com/affaan-m/everything-claude-code
# Install plugin # Install plugin
/plugin install ecc@ecc /plugin install everything-claude-code
``` ```
### 步骤 2安装规则必需 ### 步骤 2安装规则必需
@@ -206,10 +206,10 @@ npx ecc-install typescript
# /plan "Add user authentication" # /plan "Add user authentication"
# Check available commands # Check available commands
/plugin list ecc@ecc /plugin list everything-claude-code@everything-claude-code
``` ```
**搞定!** 你现在可以使用 47 个智能体、181 项技能和 79 个命令了。 **搞定!** 你现在可以使用 48 个智能体、183 项技能和 79 个命令了。
*** ***
@@ -585,7 +585,7 @@ Claude Code v2.1+ **会自动加载** 任何已安装插件中的 `hooks/hooks.j
/plugin marketplace add https://github.com/affaan-m/everything-claude-code /plugin marketplace add https://github.com/affaan-m/everything-claude-code
# Install the plugin # Install the plugin
/plugin install ecc@ecc /plugin install everything-claude-code
``` ```
或者直接添加到您的 `~/.claude/settings.json` 或者直接添加到您的 `~/.claude/settings.json`
@@ -601,7 +601,7 @@ Claude Code v2.1+ **会自动加载** 任何已安装插件中的 `hooks/hooks.j
} }
}, },
"enabledPlugins": { "enabledPlugins": {
"ecc@ecc": true "everything-claude-code@everything-claude-code": true
} }
} }
``` ```
@@ -793,7 +793,7 @@ rules/
<summary><b>如何检查已安装的代理/命令?</b></summary> <summary><b>如何检查已安装的代理/命令?</b></summary>
```bash ```bash
/plugin list ecc@ecc /plugin list everything-claude-code@everything-claude-code
``` ```
这会显示插件中所有可用的代理、命令和技能。 这会显示插件中所有可用的代理、命令和技能。
@@ -1094,9 +1094,9 @@ opencode
| 功能特性 | Claude Code | OpenCode | 状态 | | 功能特性 | Claude Code | OpenCode | 状态 |
|---------|-------------|----------|--------| |---------|-------------|----------|--------|
| 智能体 | PASS: 47 个 | PASS: 12 个 | **Claude Code 领先** | | 智能体 | PASS: 48 个 | PASS: 12 个 | **Claude Code 领先** |
| 命令 | PASS: 79 个 | PASS: 31 个 | **Claude Code 领先** | | 命令 | PASS: 79 个 | PASS: 31 个 | **Claude Code 领先** |
| 技能 | PASS: 181 项 | PASS: 37 项 | **Claude Code 领先** | | 技能 | PASS: 183 项 | PASS: 37 项 | **Claude Code 领先** |
| 钩子 | PASS: 8 种事件类型 | PASS: 11 种事件 | **OpenCode 更多!** | | 钩子 | PASS: 8 种事件类型 | PASS: 11 种事件 | **OpenCode 更多!** |
| 规则 | PASS: 29 条 | PASS: 13 条指令 | **Claude Code 领先** | | 规则 | PASS: 29 条 | PASS: 13 条指令 | **Claude Code 领先** |
| MCP 服务器 | PASS: 14 个 | PASS: 完整 | **完全对等** | | MCP 服务器 | PASS: 14 个 | PASS: 完整 | **完全对等** |
@@ -1206,9 +1206,9 @@ ECC 是**第一个最大化利用每个主要 AI 编码工具的插件**。以
| 功能特性 | Claude Code | Cursor IDE | Codex CLI | OpenCode | | 功能特性 | Claude Code | Cursor IDE | Codex CLI | OpenCode |
|---------|------------|------------|-----------|----------| |---------|------------|------------|-----------|----------|
| **智能体** | 47 | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 | | **智能体** | 48 | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 |
| **命令** | 79 | 共享 | 基于指令 | 31 | | **命令** | 79 | 共享 | 基于指令 | 31 |
| **技能** | 181 | 共享 | 10 (原生格式) | 37 | | **技能** | 183 | 共享 | 10 (原生格式) | 37 |
| **钩子事件** | 8 种类型 | 15 种类型 | 暂无 | 11 种类型 | | **钩子事件** | 8 种类型 | 15 种类型 | 暂无 | 11 种类型 |
| **钩子脚本** | 20+ 个脚本 | 16 个脚本 (DRY 适配器) | N/A | 插件钩子 | | **钩子脚本** | 20+ 个脚本 | 16 个脚本 (DRY 适配器) | N/A | 插件钩子 |
| **规则** | 34 (通用 + 语言) | 34 (YAML 前页) | 基于指令 | 13 条指令 | | **规则** | 34 (通用 + 语言) | 34 (YAML 前页) | 基于指令 | 13 条指令 |

View File

@@ -19,7 +19,7 @@ origin: ECC
此技能必须在激活前对 Claude Code 可访问。有两种引导方式: 此技能必须在激活前对 Claude Code 可访问。有两种引导方式:
1. **通过插件**: `/plugin install ecc@ecc` — 插件会自动加载此技能 1. **通过插件**: `/plugin install everything-claude-code` — 插件会自动加载此技能
2. **手动**: 仅将此技能复制到 `~/.claude/skills/configure-ecc/SKILL.md`,然后通过说 "configure ecc" 激活 2. **手动**: 仅将此技能复制到 `~/.claude/skills/configure-ecc/SKILL.md`,然后通过说 "configure ecc" 激活
*** ***

View File

@@ -70,7 +70,7 @@
/plugin marketplace add https://github.com/affaan-m/everything-claude-code /plugin marketplace add https://github.com/affaan-m/everything-claude-code
# 安裝外掛程式 # 安裝外掛程式
/plugin install ecc@ecc /plugin install everything-claude-code
``` ```
### 第二步:安裝規則(必需) ### 第二步:安裝規則(必需)
@@ -95,7 +95,7 @@ cp -r everything-claude-code/rules/* ~/.claude/rules/
# /plan "新增使用者認證" # /plan "新增使用者認證"
# 查看可用指令 # 查看可用指令
/plugin list ecc@ecc /plugin list everything-claude-code@everything-claude-code
``` ```
**完成!** 您現在使用 15+ 代理程式、30+ 技能和 20+ 指令。 **完成!** 您現在使用 15+ 代理程式、30+ 技能和 20+ 指令。
@@ -270,7 +270,7 @@ everything-claude-code/
/plugin marketplace add https://github.com/affaan-m/everything-claude-code /plugin marketplace add https://github.com/affaan-m/everything-claude-code
# 安裝外掛程式 # 安裝外掛程式
/plugin install ecc@ecc /plugin install everything-claude-code
``` ```
或直接新增到您的 `~/.claude/settings.json` 或直接新增到您的 `~/.claude/settings.json`
@@ -286,7 +286,7 @@ everything-claude-code/
} }
}, },
"enabledPlugins": { "enabledPlugins": {
"ecc@ecc": true "everything-claude-code@everything-claude-code": true
} }
} }
``` ```

View File

@@ -8,8 +8,11 @@ import tkinter as tk
from tkinter import ttk, scrolledtext, messagebox from tkinter import ttk, scrolledtext, messagebox
import os import os
import json import json
import subprocess
from typing import Dict, List, Optional from typing import Dict, List, Optional
from scripts.lib.ecc_dashboard_runtime import build_terminal_launch, maximize_window
# ============================================================================ # ============================================================================
# DATA LOADERS - Load ECC data from the project # DATA LOADERS - Load ECC data from the project
# ============================================================================ # ============================================================================
@@ -18,6 +21,7 @@ def get_project_path() -> str:
"""Get the ECC project path - assumes this script is run from the project dir""" """Get the ECC project path - assumes this script is run from the project dir"""
return os.path.dirname(os.path.abspath(__file__)) return os.path.dirname(os.path.abspath(__file__))
def load_agents(project_path: str) -> List[Dict]: def load_agents(project_path: str) -> List[Dict]:
"""Load agents from AGENTS.md""" """Load agents from AGENTS.md"""
agents_file = os.path.join(project_path, "AGENTS.md") agents_file = os.path.join(project_path, "AGENTS.md")
@@ -257,7 +261,7 @@ class ECCDashboard(tk.Tk):
self.project_path = get_project_path() self.project_path = get_project_path()
self.title("ECC Dashboard - Everything Claude Code") self.title("ECC Dashboard - Everything Claude Code")
self.state('zoomed') maximize_window(self)
try: try:
self.icon_image = tk.PhotoImage(file='assets/images/ecc-logo.png') self.icon_image = tk.PhotoImage(file='assets/images/ecc-logo.png')
@@ -789,14 +793,9 @@ Project: github.com/affaan-m/everything-claude-code"""
def open_terminal(self): def open_terminal(self):
"""Open terminal at project path""" """Open terminal at project path"""
import subprocess
path = self.path_entry.get() path = self.path_entry.get()
if os.name == 'nt': # Windows argv, kwargs = build_terminal_launch(path)
subprocess.Popen(['cmd', '/c', 'start', 'cmd', '/k', f'cd /d "{path}"']) subprocess.Popen(argv, **kwargs)
elif os.uname().sysname == 'Darwin': # macOS
subprocess.Popen(['open', '-a', 'Terminal', path])
else: # Linux
subprocess.Popen(['x-terminal-emulator', '-e', f'cd {path}'])
def open_readme(self): def open_readme(self):
"""Open README in default browser/reader""" """Open README in default browser/reader"""
@@ -911,4 +910,4 @@ def main():
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@@ -18,7 +18,7 @@ User request → Claude picks a tool → PreToolUse hook runs → Tool executes
## Installing These Hooks Manually ## Installing These Hooks Manually
For Claude Code manual installs, do not paste the raw repo `hooks.json` into `~/.claude/settings.json` or copy it directly into `~/.claude/hooks/hooks.json`. The checked-in file still contains `${CLAUDE_PLUGIN_ROOT}` placeholders and is meant to be installed through the ECC installer or loaded as a plugin. For Claude Code manual installs, do not paste the raw repo `hooks.json` into `~/.claude/settings.json` or copy it directly into `~/.claude/hooks/hooks.json`. The checked-in file is plugin/repo-oriented and is meant to be installed through the ECC installer or loaded as a plugin.
Use the installer instead so hook commands are rewritten against your actual Claude root: Use the installer instead so hook commands are rewritten against your actual Claude root:

View File

@@ -7,62 +7,33 @@
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"pre:bash:block-no-verify\" \"scripts/hooks/block-no-verify.js\" \"minimal,standard,strict\"" "command": [
"node",
"-e",
"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\"ecc\"],[\"ecc@ecc\"],[\"marketplace\",\"ecc\"],[\"everything-claude-code\"],[\"everything-claude-code@everything-claude-code\"],[\"marketplace\",\"everything-claude-code\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\"ecc\",\"everything-claude-code\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)",
"node",
"scripts/hooks/pre-bash-dispatcher.js"
]
} }
], ],
"description": "Block git hook-bypass flag to protect pre-commit, commit-msg, and pre-push hooks from being skipped", "description": "Consolidated Bash preflight dispatcher for quality, tmux, push, and GateGuard checks",
"id": "pre:bash:block-no-verify" "id": "pre:bash:dispatcher"
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/auto-tmux-dev.js\""
}
],
"description": "Auto-start dev servers in tmux with directory-based session names",
"id": "pre:bash:auto-tmux-dev"
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"pre:bash:tmux-reminder\" \"scripts/hooks/pre-bash-tmux-reminder.js\" \"strict\""
}
],
"description": "Reminder to use tmux for long-running commands",
"id": "pre:bash:tmux-reminder"
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"pre:bash:git-push-reminder\" \"scripts/hooks/pre-bash-git-push-reminder.js\" \"strict\""
}
],
"description": "Reminder before git push to review changes",
"id": "pre:bash:git-push-reminder"
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"pre:bash:commit-quality\" \"scripts/hooks/pre-bash-commit-quality.js\" \"strict\""
}
],
"description": "Pre-commit quality check: lint staged files, validate commit message format, detect console.log/debugger/secrets before committing",
"id": "pre:bash:commit-quality"
}, },
{ {
"matcher": "Write", "matcher": "Write",
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"pre:write:doc-file-warning\" \"scripts/hooks/doc-file-warning.js\" \"standard,strict\"" "command": [
"node",
"-e",
"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\"ecc\"],[\"ecc@ecc\"],[\"marketplace\",\"ecc\"],[\"everything-claude-code\"],[\"everything-claude-code@everything-claude-code\"],[\"marketplace\",\"everything-claude-code\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\"ecc\",\"everything-claude-code\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)",
"node",
"scripts/hooks/run-with-flags.js",
"pre:write:doc-file-warning",
"scripts/hooks/doc-file-warning.js",
"standard,strict"
]
} }
], ],
"description": "Doc file warning: warn about non-standard documentation files (exit code 0; warns only)", "description": "Doc file warning: warn about non-standard documentation files (exit code 0; warns only)",
@@ -73,7 +44,16 @@
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"pre:edit-write:suggest-compact\" \"scripts/hooks/suggest-compact.js\" \"standard,strict\"" "command": [
"node",
"-e",
"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\"ecc\"],[\"ecc@ecc\"],[\"marketplace\",\"ecc\"],[\"everything-claude-code\"],[\"everything-claude-code@everything-claude-code\"],[\"marketplace\",\"everything-claude-code\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\"ecc\",\"everything-claude-code\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)",
"node",
"scripts/hooks/run-with-flags.js",
"pre:edit-write:suggest-compact",
"scripts/hooks/suggest-compact.js",
"standard,strict"
]
} }
], ],
"description": "Suggest manual compaction at logical intervals", "description": "Suggest manual compaction at logical intervals",
@@ -84,7 +64,16 @@
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags-shell.sh\" \"pre:observe\" \"skills/continuous-learning-v2/hooks/observe.sh\" \"standard,strict\"", "command": [
"node",
"-e",
"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\"ecc\"],[\"ecc@ecc\"],[\"marketplace\",\"ecc\"],[\"everything-claude-code\"],[\"everything-claude-code@everything-claude-code\"],[\"marketplace\",\"everything-claude-code\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\"ecc\",\"everything-claude-code\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)",
"shell",
"scripts/hooks/run-with-flags-shell.sh",
"pre:observe",
"skills/continuous-learning-v2/hooks/observe.sh",
"standard,strict"
],
"async": true, "async": true,
"timeout": 10 "timeout": 10
} }
@@ -97,7 +86,16 @@
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"pre:governance-capture\" \"scripts/hooks/governance-capture.js\" \"standard,strict\"", "command": [
"node",
"-e",
"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\"ecc\"],[\"ecc@ecc\"],[\"marketplace\",\"ecc\"],[\"everything-claude-code\"],[\"everything-claude-code@everything-claude-code\"],[\"marketplace\",\"everything-claude-code\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\"ecc\",\"everything-claude-code\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)",
"node",
"scripts/hooks/run-with-flags.js",
"pre:governance-capture",
"scripts/hooks/governance-capture.js",
"standard,strict"
],
"timeout": 10 "timeout": 10
} }
], ],
@@ -109,7 +107,16 @@
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"pre:config-protection\" \"scripts/hooks/config-protection.js\" \"standard,strict\"", "command": [
"node",
"-e",
"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\"ecc\"],[\"ecc@ecc\"],[\"marketplace\",\"ecc\"],[\"everything-claude-code\"],[\"everything-claude-code@everything-claude-code\"],[\"marketplace\",\"everything-claude-code\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\"ecc\",\"everything-claude-code\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)",
"node",
"scripts/hooks/run-with-flags.js",
"pre:config-protection",
"scripts/hooks/config-protection.js",
"standard,strict"
],
"timeout": 5 "timeout": 5
} }
], ],
@@ -121,7 +128,16 @@
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"pre:mcp-health-check\" \"scripts/hooks/mcp-health-check.js\" \"standard,strict\"" "command": [
"node",
"-e",
"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\"ecc\"],[\"ecc@ecc\"],[\"marketplace\",\"ecc\"],[\"everything-claude-code\"],[\"everything-claude-code@everything-claude-code\"],[\"marketplace\",\"everything-claude-code\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\"ecc\",\"everything-claude-code\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)",
"node",
"scripts/hooks/run-with-flags.js",
"pre:mcp-health-check",
"scripts/hooks/mcp-health-check.js",
"standard,strict"
]
} }
], ],
"description": "Check MCP server health before MCP tool execution and block unhealthy MCP calls", "description": "Check MCP server health before MCP tool execution and block unhealthy MCP calls",
@@ -132,24 +148,21 @@
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"pre:edit-write:gateguard-fact-force\" \"scripts/hooks/gateguard-fact-force.js\" \"standard,strict\"", "command": [
"node",
"-e",
"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\"ecc\"],[\"ecc@ecc\"],[\"marketplace\",\"ecc\"],[\"everything-claude-code\"],[\"everything-claude-code@everything-claude-code\"],[\"marketplace\",\"everything-claude-code\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\"ecc\",\"everything-claude-code\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)",
"node",
"scripts/hooks/run-with-flags.js",
"pre:edit-write:gateguard-fact-force",
"scripts/hooks/gateguard-fact-force.js",
"standard,strict"
],
"timeout": 5 "timeout": 5
} }
], ],
"description": "Fact-forcing gate: block first Edit/Write/MultiEdit per file and demand investigation (importers, data schemas, user instruction) before allowing", "description": "Fact-forcing gate: block first Edit/Write/MultiEdit per file and demand investigation (importers, data schemas, user instruction) before allowing",
"id": "pre:edit-write:gateguard-fact-force" "id": "pre:edit-write:gateguard-fact-force"
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"pre:bash:gateguard-fact-force\" \"scripts/hooks/gateguard-fact-force.js\" \"standard,strict\"",
"timeout": 5
}
],
"description": "Fact-forcing gate: block destructive Bash commands and demand rollback plan; quote user instruction on first Bash per session",
"id": "pre:bash:gateguard-fact-force"
} }
], ],
"PreCompact": [ "PreCompact": [
@@ -158,7 +171,16 @@
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"pre:compact\" \"scripts/hooks/pre-compact.js\" \"standard,strict\"" "command": [
"node",
"-e",
"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\"ecc\"],[\"ecc@ecc\"],[\"marketplace\",\"ecc\"],[\"everything-claude-code\"],[\"everything-claude-code@everything-claude-code\"],[\"marketplace\",\"everything-claude-code\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\"ecc\",\"everything-claude-code\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)",
"node",
"scripts/hooks/run-with-flags.js",
"pre:compact",
"scripts/hooks/pre-compact.js",
"standard,strict"
]
} }
], ],
"description": "Save state before context compaction", "description": "Save state before context compaction",
@@ -171,7 +193,13 @@
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/session-start-bootstrap.js\"" "command": [
"node",
"-e",
"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\"ecc\"],[\"ecc@ecc\"],[\"marketplace\",\"ecc\"],[\"everything-claude-code\"],[\"everything-claude-code@everything-claude-code\"],[\"marketplace\",\"everything-claude-code\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\"ecc\",\"everything-claude-code\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)",
"node",
"scripts/hooks/session-start-bootstrap.js"
]
} }
], ],
"description": "Load previous context and detect package manager on new session", "description": "Load previous context and detect package manager on new session",
@@ -184,53 +212,35 @@
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/post-bash-command-log.js\" audit" "command": [
} "node",
], "-e",
"description": "Audit log all bash commands to ~/.claude/bash-commands.log", "const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\"ecc\"],[\"ecc@ecc\"],[\"marketplace\",\"ecc\"],[\"everything-claude-code\"],[\"everything-claude-code@everything-claude-code\"],[\"marketplace\",\"everything-claude-code\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\"ecc\",\"everything-claude-code\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)",
"id": "post:bash:command-log-audit" "node",
}, "scripts/hooks/post-bash-dispatcher.js"
{ ],
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/post-bash-command-log.js\" cost"
}
],
"description": "Cost tracker - log bash tool usage with timestamps",
"id": "post:bash:command-log-cost"
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"post:bash:pr-created\" \"scripts/hooks/post-bash-pr-created.js\" \"standard,strict\""
}
],
"description": "Log PR URL and provide review command after PR creation",
"id": "post:bash:pr-created"
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"post:bash:build-complete\" \"scripts/hooks/post-bash-build-complete.js\" \"standard,strict\"",
"async": true, "async": true,
"timeout": 30 "timeout": 30
} }
], ],
"description": "Example: async hook for build analysis (runs in background without blocking)", "description": "Consolidated Bash postflight dispatcher for logging, PR, and build notifications",
"id": "post:bash:build-complete" "id": "post:bash:dispatcher"
}, },
{ {
"matcher": "Edit|Write|MultiEdit", "matcher": "Edit|Write|MultiEdit",
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"post:quality-gate\" \"scripts/hooks/quality-gate.js\" \"standard,strict\"", "command": [
"node",
"-e",
"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\"ecc\"],[\"ecc@ecc\"],[\"marketplace\",\"ecc\"],[\"everything-claude-code\"],[\"everything-claude-code@everything-claude-code\"],[\"marketplace\",\"everything-claude-code\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\"ecc\",\"everything-claude-code\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)",
"node",
"scripts/hooks/run-with-flags.js",
"post:quality-gate",
"scripts/hooks/quality-gate.js",
"standard,strict"
],
"async": true, "async": true,
"timeout": 30 "timeout": 30
} }
@@ -243,7 +253,16 @@
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"post:edit:design-quality-check\" \"scripts/hooks/design-quality-check.js\" \"standard,strict\"", "command": [
"node",
"-e",
"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\"ecc\"],[\"ecc@ecc\"],[\"marketplace\",\"ecc\"],[\"everything-claude-code\"],[\"everything-claude-code@everything-claude-code\"],[\"marketplace\",\"everything-claude-code\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\"ecc\",\"everything-claude-code\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)",
"node",
"scripts/hooks/run-with-flags.js",
"post:edit:design-quality-check",
"scripts/hooks/design-quality-check.js",
"standard,strict"
],
"timeout": 10 "timeout": 10
} }
], ],
@@ -255,7 +274,16 @@
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"post:edit:accumulate\" \"scripts/hooks/post-edit-accumulator.js\" \"standard,strict\"" "command": [
"node",
"-e",
"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\"ecc\"],[\"ecc@ecc\"],[\"marketplace\",\"ecc\"],[\"everything-claude-code\"],[\"everything-claude-code@everything-claude-code\"],[\"marketplace\",\"everything-claude-code\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\"ecc\",\"everything-claude-code\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)",
"node",
"scripts/hooks/run-with-flags.js",
"post:edit:accumulate",
"scripts/hooks/post-edit-accumulator.js",
"standard,strict"
]
} }
], ],
"description": "Record edited JS/TS file paths for batch format+typecheck at Stop time", "description": "Record edited JS/TS file paths for batch format+typecheck at Stop time",
@@ -266,7 +294,16 @@
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"post:edit:console-warn\" \"scripts/hooks/post-edit-console-warn.js\" \"standard,strict\"" "command": [
"node",
"-e",
"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\"ecc\"],[\"ecc@ecc\"],[\"marketplace\",\"ecc\"],[\"everything-claude-code\"],[\"everything-claude-code@everything-claude-code\"],[\"marketplace\",\"everything-claude-code\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\"ecc\",\"everything-claude-code\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)",
"node",
"scripts/hooks/run-with-flags.js",
"post:edit:console-warn",
"scripts/hooks/post-edit-console-warn.js",
"standard,strict"
]
} }
], ],
"description": "Warn about console.log statements after edits", "description": "Warn about console.log statements after edits",
@@ -277,7 +314,16 @@
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"post:governance-capture\" \"scripts/hooks/governance-capture.js\" \"standard,strict\"", "command": [
"node",
"-e",
"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\"ecc\"],[\"ecc@ecc\"],[\"marketplace\",\"ecc\"],[\"everything-claude-code\"],[\"everything-claude-code@everything-claude-code\"],[\"marketplace\",\"everything-claude-code\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\"ecc\",\"everything-claude-code\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)",
"node",
"scripts/hooks/run-with-flags.js",
"post:governance-capture",
"scripts/hooks/governance-capture.js",
"standard,strict"
],
"timeout": 10 "timeout": 10
} }
], ],
@@ -289,7 +335,16 @@
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"post:session-activity-tracker\" \"scripts/hooks/session-activity-tracker.js\" \"standard,strict\"", "command": [
"node",
"-e",
"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\"ecc\"],[\"ecc@ecc\"],[\"marketplace\",\"ecc\"],[\"everything-claude-code\"],[\"everything-claude-code@everything-claude-code\"],[\"marketplace\",\"everything-claude-code\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\"ecc\",\"everything-claude-code\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)",
"node",
"scripts/hooks/run-with-flags.js",
"post:session-activity-tracker",
"scripts/hooks/session-activity-tracker.js",
"standard,strict"
],
"timeout": 10 "timeout": 10
} }
], ],
@@ -301,7 +356,16 @@
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags-shell.sh\" \"post:observe\" \"skills/continuous-learning-v2/hooks/observe.sh\" \"standard,strict\"", "command": [
"node",
"-e",
"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\"ecc\"],[\"ecc@ecc\"],[\"marketplace\",\"ecc\"],[\"everything-claude-code\"],[\"everything-claude-code@everything-claude-code\"],[\"marketplace\",\"everything-claude-code\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\"ecc\",\"everything-claude-code\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)",
"shell",
"scripts/hooks/run-with-flags-shell.sh",
"post:observe",
"skills/continuous-learning-v2/hooks/observe.sh",
"standard,strict"
],
"async": true, "async": true,
"timeout": 10 "timeout": 10
} }
@@ -316,7 +380,16 @@
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"post:mcp-health-check\" \"scripts/hooks/mcp-health-check.js\" \"standard,strict\"" "command": [
"node",
"-e",
"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\"ecc\"],[\"ecc@ecc\"],[\"marketplace\",\"ecc\"],[\"everything-claude-code\"],[\"everything-claude-code@everything-claude-code\"],[\"marketplace\",\"everything-claude-code\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\"ecc\",\"everything-claude-code\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)",
"node",
"scripts/hooks/run-with-flags.js",
"post:mcp-health-check",
"scripts/hooks/mcp-health-check.js",
"standard,strict"
]
} }
], ],
"description": "Track failed MCP tool calls, mark unhealthy servers, and attempt reconnect", "description": "Track failed MCP tool calls, mark unhealthy servers, and attempt reconnect",

View File

@@ -73,7 +73,7 @@ function validateHookEntry(hook, label) {
console.error(`ERROR: ${label} missing or invalid 'command' field`); console.error(`ERROR: ${label} missing or invalid 'command' field`);
hasErrors = true; hasErrors = true;
} else if (typeof hook.command === 'string') { } else if (typeof hook.command === 'string') {
const nodeEMatch = hook.command.match(/^node -e "(.*)"$/s); const nodeEMatch = hook.command.match(/^node -e "((?:[^"\\]|\\.)*)"(?:\s|$)/s);
if (nodeEMatch) { if (nodeEMatch) {
try { try {
new vm.Script(nodeEMatch[1].replace(/\\\\/g, '\\').replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\t/g, '\t')); new vm.Script(nodeEMatch[1].replace(/\\\\/g, '\\').replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\t/g, '\t'));

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env node #!/usr/bin/env node
const fs = require('fs'); const fs = require('fs');
const os = require('os');
const path = require('path'); const path = require('path');
const CATEGORIES = [ const CATEGORIES = [
@@ -187,7 +188,7 @@ function detectTargetMode(rootDir) {
} }
function findPluginInstall(rootDir) { function findPluginInstall(rootDir) {
const homeDir = process.env.HOME || ''; const homeDir = process.env.HOME || process.env.USERPROFILE || os.homedir() || '';
const pluginDirs = [ const pluginDirs = [
'ecc', 'ecc',
'ecc@ecc', 'ecc@ecc',

View File

@@ -30,19 +30,10 @@ const { spawnSync } = require('child_process');
const MAX_STDIN = 1024 * 1024; // 1MB limit const MAX_STDIN = 1024 * 1024; // 1MB limit
let data = ''; let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => { function run(rawInput) {
if (data.length < MAX_STDIN) {
const remaining = MAX_STDIN - data.length;
data += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
let input;
try { try {
input = JSON.parse(data); const input = typeof rawInput === 'string' ? JSON.parse(rawInput) : rawInput;
const cmd = input.tool_input?.command || ''; const cmd = input.tool_input?.command || '';
// Detect dev server commands: npm run dev, pnpm dev, yarn dev, bun run dev // Detect dev server commands: npm run dev, pnpm dev, yarn dev, bun run dev
@@ -60,7 +51,13 @@ process.stdin.on('end', () => {
// Windows: open in a new cmd window (non-blocking) // Windows: open in a new cmd window (non-blocking)
// Escape double quotes in cmd for cmd /k syntax // Escape double quotes in cmd for cmd /k syntax
const escapedCmd = cmd.replace(/"/g, '""'); const escapedCmd = cmd.replace(/"/g, '""');
input.tool_input.command = `start "DevServer-${sessionName}" cmd /k "${escapedCmd}"`; return JSON.stringify({
...input,
tool_input: {
...input.tool_input,
command: `start "DevServer-${sessionName}" cmd /k "${escapedCmd}"`,
},
});
} else { } else {
// Unix (macOS/Linux): Check tmux is available before transforming // Unix (macOS/Linux): Check tmux is available before transforming
const tmuxCheck = spawnSync('which', ['tmux'], { encoding: 'utf8' }); const tmuxCheck = spawnSync('which', ['tmux'], { encoding: 'utf8' });
@@ -73,16 +70,38 @@ process.stdin.on('end', () => {
// 2. Create new detached session with the dev command // 2. Create new detached session with the dev command
// 3. Echo confirmation message with instructions for viewing logs // 3. Echo confirmation message with instructions for viewing logs
const transformedCmd = `SESSION="${sessionName}"; tmux kill-session -t "$SESSION" 2>/dev/null || true; tmux new-session -d -s "$SESSION" '${escapedCmd}' && echo "[Hook] Dev server started in tmux session '${sessionName}'. View logs: tmux capture-pane -t ${sessionName} -p -S -100"`; const transformedCmd = `SESSION="${sessionName}"; tmux kill-session -t "$SESSION" 2>/dev/null || true; tmux new-session -d -s "$SESSION" '${escapedCmd}' && echo "[Hook] Dev server started in tmux session '${sessionName}'. View logs: tmux capture-pane -t ${sessionName} -p -S -100"`;
return JSON.stringify({
input.tool_input.command = transformedCmd; ...input,
tool_input: {
...input.tool_input,
command: transformedCmd,
},
});
} }
// else: tmux not found, pass through original command unchanged // else: tmux not found, pass through original command unchanged
} }
} }
process.stdout.write(JSON.stringify(input));
return JSON.stringify(input);
} catch { } catch {
// Invalid input — pass through original data unchanged // Invalid input — pass through original data unchanged
process.stdout.write(data); return typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput);
} }
process.exit(0); }
});
if (require.main === module) {
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (data.length < MAX_STDIN) {
const remaining = MAX_STDIN - data.length;
data += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
process.stdout.write(run(data));
process.exit(0);
});
}
module.exports = { run };

View File

@@ -0,0 +1,177 @@
#!/usr/bin/env node
'use strict';
const { isHookEnabled } = require('../lib/hook-flags');
const { run: runBlockNoVerify } = require('./block-no-verify');
const { run: runAutoTmuxDev } = require('./auto-tmux-dev');
const { run: runTmuxReminder } = require('./pre-bash-tmux-reminder');
const { run: runGitPushReminder } = require('./pre-bash-git-push-reminder');
const { run: runCommitQuality } = require('./pre-bash-commit-quality');
const { run: runGateGuard } = require('./gateguard-fact-force');
const { run: runCommandLog } = require('./post-bash-command-log');
const { run: runPrCreated } = require('./post-bash-pr-created');
const { run: runBuildComplete } = require('./post-bash-build-complete');
const MAX_STDIN = 1024 * 1024;
const PRE_BASH_HOOKS = [
{
id: 'pre:bash:block-no-verify',
profiles: 'minimal,standard,strict',
run: rawInput => runBlockNoVerify(rawInput),
},
{
id: 'pre:bash:auto-tmux-dev',
run: rawInput => runAutoTmuxDev(rawInput),
},
{
id: 'pre:bash:tmux-reminder',
profiles: 'strict',
run: rawInput => runTmuxReminder(rawInput),
},
{
id: 'pre:bash:git-push-reminder',
profiles: 'strict',
run: rawInput => runGitPushReminder(rawInput),
},
{
id: 'pre:bash:commit-quality',
profiles: 'strict',
run: rawInput => runCommitQuality(rawInput),
},
{
id: 'pre:bash:gateguard-fact-force',
profiles: 'standard,strict',
run: rawInput => runGateGuard(rawInput),
},
];
const POST_BASH_HOOKS = [
{
id: 'post:bash:command-log-audit',
run: rawInput => runCommandLog(rawInput, 'audit'),
},
{
id: 'post:bash:command-log-cost',
run: rawInput => runCommandLog(rawInput, 'cost'),
},
{
id: 'post:bash:pr-created',
profiles: 'standard,strict',
run: rawInput => runPrCreated(rawInput),
},
{
id: 'post:bash:build-complete',
profiles: 'standard,strict',
run: rawInput => runBuildComplete(rawInput),
},
];
function readStdinRaw() {
return new Promise(resolve => {
let raw = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => resolve(raw));
process.stdin.on('error', () => resolve(raw));
});
}
function normalizeHookResult(previousRaw, output) {
if (typeof output === 'string' || Buffer.isBuffer(output)) {
return {
raw: String(output),
stderr: '',
exitCode: 0,
};
}
if (output && typeof output === 'object') {
const nextRaw = Object.prototype.hasOwnProperty.call(output, 'stdout')
? String(output.stdout ?? '')
: !Number.isInteger(output.exitCode) || output.exitCode === 0
? previousRaw
: '';
return {
raw: nextRaw,
stderr: typeof output.stderr === 'string' ? output.stderr : '',
exitCode: Number.isInteger(output.exitCode) ? output.exitCode : 0,
};
}
return {
raw: previousRaw,
stderr: '',
exitCode: 0,
};
}
function runHooks(rawInput, hooks) {
let currentRaw = rawInput;
let stderr = '';
for (const hook of hooks) {
if (!isHookEnabled(hook.id, { profiles: hook.profiles })) {
continue;
}
try {
const result = normalizeHookResult(currentRaw, hook.run(currentRaw));
currentRaw = result.raw;
if (result.stderr) {
stderr += result.stderr.endsWith('\n') ? result.stderr : `${result.stderr}\n`;
}
if (result.exitCode !== 0) {
return { output: currentRaw, stderr, exitCode: result.exitCode };
}
} catch (error) {
stderr += `[Hook] ${hook.id} failed: ${error.message}\n`;
}
}
return { output: currentRaw, stderr, exitCode: 0 };
}
function runPreBash(rawInput) {
return runHooks(rawInput, PRE_BASH_HOOKS);
}
function runPostBash(rawInput) {
return runHooks(rawInput, POST_BASH_HOOKS);
}
async function main() {
const mode = process.argv[2];
const raw = await readStdinRaw();
const result = mode === 'post'
? runPostBash(raw)
: runPreBash(raw);
if (result.stderr) {
process.stderr.write(result.stderr);
}
process.stdout.write(result.output);
process.exit(result.exitCode);
}
if (require.main === module) {
main().catch(error => {
process.stderr.write(`[Hook] bash-hook-dispatcher failed: ${error.message}\n`);
process.exit(0);
});
}
module.exports = {
PRE_BASH_HOOKS,
POST_BASH_HOOKS,
runPreBash,
runPostBash,
};

View File

@@ -27,13 +27,12 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
// Session state — scoped per session to avoid cross-session races. // Session state — scoped per session to avoid cross-session races.
// Uses CLAUDE_SESSION_ID (set by Claude Code) or falls back to PID-based isolation.
const STATE_DIR = process.env.GATEGUARD_STATE_DIR || path.join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.gateguard'); const STATE_DIR = process.env.GATEGUARD_STATE_DIR || path.join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.gateguard');
const SESSION_ID = process.env.CLAUDE_SESSION_ID || process.env.ECC_SESSION_ID || `pid-${process.ppid || process.pid}`; let activeStateFile = null;
const STATE_FILE = path.join(STATE_DIR, `state-${SESSION_ID.replace(/[^a-zA-Z0-9_-]/g, '_')}.json`);
// State expires after 30 minutes of inactivity // State expires after 30 minutes of inactivity
const SESSION_TIMEOUT_MS = 30 * 60 * 1000; const SESSION_TIMEOUT_MS = 30 * 60 * 1000;
const READ_HEARTBEAT_MS = 60 * 1000;
// Maximum checked entries to prevent unbounded growth // Maximum checked entries to prevent unbounded growth
const MAX_CHECKED_ENTRIES = 500; const MAX_CHECKED_ENTRIES = 500;
@@ -44,13 +43,65 @@ const DESTRUCTIVE_BASH = /\b(rm\s+-rf|git\s+reset\s+--hard|git\s+checkout\s+--|g
// --- State management (per-session, atomic writes, bounded) --- // --- State management (per-session, atomic writes, bounded) ---
function sanitizeSessionKey(value) {
const raw = String(value || '').trim();
if (!raw) {
return '';
}
const sanitized = raw.replace(/[^a-zA-Z0-9_-]/g, '_');
if (sanitized && sanitized.length <= 64) {
return sanitized;
}
return hashSessionKey('sid', raw);
}
function hashSessionKey(prefix, value) {
return `${prefix}-${crypto.createHash('sha256').update(String(value)).digest('hex').slice(0, 24)}`;
}
function resolveSessionKey(data) {
const directCandidates = [
data && data.session_id,
data && data.sessionId,
data && data.session && data.session.id,
process.env.CLAUDE_SESSION_ID,
process.env.ECC_SESSION_ID,
];
for (const candidate of directCandidates) {
const sanitized = sanitizeSessionKey(candidate);
if (sanitized) {
return sanitized;
}
}
const transcriptPath = (data && (data.transcript_path || data.transcriptPath)) || process.env.CLAUDE_TRANSCRIPT_PATH;
if (transcriptPath && String(transcriptPath).trim()) {
return hashSessionKey('tx', path.resolve(String(transcriptPath).trim()));
}
const projectFingerprint = process.env.CLAUDE_PROJECT_DIR || process.cwd();
return hashSessionKey('proj', path.resolve(projectFingerprint));
}
function getStateFile(data) {
if (!activeStateFile) {
const sessionKey = resolveSessionKey(data);
activeStateFile = path.join(STATE_DIR, `state-${sessionKey}.json`);
}
return activeStateFile;
}
function loadState() { function loadState() {
const stateFile = getStateFile();
try { try {
if (fs.existsSync(STATE_FILE)) { if (fs.existsSync(stateFile)) {
const state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
const lastActive = state.last_active || 0; const lastActive = state.last_active || 0;
if (Date.now() - lastActive > SESSION_TIMEOUT_MS) { if (Date.now() - lastActive > SESSION_TIMEOUT_MS) {
try { fs.unlinkSync(STATE_FILE); } catch (_) { /* ignore */ } try { fs.unlinkSync(stateFile); } catch (_) { /* ignore */ }
return { checked: [], last_active: Date.now() }; return { checked: [], last_active: Date.now() };
} }
return state; return state;
@@ -75,15 +126,30 @@ function pruneCheckedEntries(checked) {
} }
function saveState(state) { function saveState(state) {
const stateFile = getStateFile();
let tmpFile = null;
try { try {
state.last_active = Date.now(); state.last_active = Date.now();
state.checked = pruneCheckedEntries(state.checked); state.checked = pruneCheckedEntries(state.checked);
fs.mkdirSync(STATE_DIR, { recursive: true }); fs.mkdirSync(STATE_DIR, { recursive: true });
// Atomic write: temp file + rename prevents partial reads // Atomic write: temp file + rename prevents partial reads
const tmpFile = STATE_FILE + '.tmp.' + process.pid; tmpFile = stateFile + '.tmp.' + process.pid;
fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), 'utf8'); fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), 'utf8');
fs.renameSync(tmpFile, STATE_FILE); try {
} catch (_) { /* ignore */ } fs.renameSync(tmpFile, stateFile);
} catch (error) {
if (error && (error.code === 'EEXIST' || error.code === 'EPERM')) {
try { fs.unlinkSync(stateFile); } catch (_) { /* ignore */ }
fs.renameSync(tmpFile, stateFile);
} else {
throw error;
}
}
} catch (_) {
if (tmpFile) {
try { fs.unlinkSync(tmpFile); } catch (_) { /* ignore */ }
}
}
} }
function markChecked(key) { function markChecked(key) {
@@ -97,7 +163,9 @@ function markChecked(key) {
function isChecked(key) { function isChecked(key) {
const state = loadState(); const state = loadState();
const found = state.checked.includes(key); const found = state.checked.includes(key);
saveState(state); if (found && Date.now() - (state.last_active || 0) > READ_HEARTBEAT_MS) {
saveState(state);
}
return found; return found;
} }
@@ -109,9 +177,13 @@ function isChecked(key) {
for (const f of files) { for (const f of files) {
if (!f.startsWith('state-') || !f.endsWith('.json')) continue; if (!f.startsWith('state-') || !f.endsWith('.json')) continue;
const fp = path.join(STATE_DIR, f); const fp = path.join(STATE_DIR, f);
const stat = fs.statSync(fp); try {
if (now - stat.mtimeMs > SESSION_TIMEOUT_MS * 2) { const stat = fs.statSync(fp);
fs.unlinkSync(fp); if (now - stat.mtimeMs > SESSION_TIMEOUT_MS * 2) {
fs.unlinkSync(fp);
}
} catch (_) {
// Ignore files that disappear between readdir/stat/unlink.
} }
} }
} catch (_) { /* ignore */ } } catch (_) { /* ignore */ }
@@ -121,7 +193,64 @@ function isChecked(key) {
function sanitizePath(filePath) { function sanitizePath(filePath) {
// Strip control chars (including null), bidi overrides, and newlines // Strip control chars (including null), bidi overrides, and newlines
return filePath.replace(/[\x00-\x1f\x7f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, ' ').trim().slice(0, 500); let sanitized = '';
for (const char of String(filePath || '')) {
const code = char.codePointAt(0);
const isAsciiControl = code <= 0x1f || code === 0x7f;
const isBidiOverride = (code >= 0x200e && code <= 0x200f) || (code >= 0x202a && code <= 0x202e) || (code >= 0x2066 && code <= 0x2069);
sanitized += (isAsciiControl || isBidiOverride) ? ' ' : char;
}
return sanitized.trim().slice(0, 500);
}
function normalizeForMatch(value) {
return String(value || '').replace(/\\/g, '/').toLowerCase();
}
function isClaudeSettingsPath(filePath) {
const normalized = normalizeForMatch(filePath);
return /(^|\/)\.claude\/settings(?:\.[^/]+)?\.json$/.test(normalized);
}
function isReadOnlyGitIntrospection(command) {
const trimmed = String(command || '').trim();
if (!trimmed || /[\r\n;&|><`$()]/.test(trimmed)) {
return false;
}
const tokens = trimmed.split(/\s+/);
if (tokens[0] !== 'git' || tokens.length < 2) {
return false;
}
const subcommand = tokens[1].toLowerCase();
const args = tokens.slice(2);
if (subcommand === 'status') {
return args.every(arg => ['--porcelain', '--short', '--branch'].includes(arg));
}
if (subcommand === 'diff') {
return args.length <= 1 && args.every(arg => ['--name-only', '--name-status'].includes(arg));
}
if (subcommand === 'log') {
return args.every(arg => arg === '--oneline' || /^--max-count=\d+$/.test(arg));
}
if (subcommand === 'show') {
return args.length === 1 && !args[0].startsWith('--') && /^[a-zA-Z0-9._:/-]+$/.test(args[0]);
}
if (subcommand === 'branch') {
return args.length === 1 && args[0] === '--show-current';
}
if (subcommand === 'rev-parse') {
return args.length === 2 && args[0] === '--abbrev-ref' && /^head$/i.test(args[1]);
}
return false;
} }
// --- Gate messages --- // --- Gate messages ---
@@ -205,6 +334,8 @@ function run(rawInput) {
} catch (_) { } catch (_) {
return rawInput; // allow on parse error return rawInput; // allow on parse error
} }
activeStateFile = null;
getStateFile(data);
const rawToolName = data.tool_name || ''; const rawToolName = data.tool_name || '';
const toolInput = data.tool_input || {}; const toolInput = data.tool_input || {};
@@ -214,7 +345,7 @@ function run(rawInput) {
if (toolName === 'Edit' || toolName === 'Write') { if (toolName === 'Edit' || toolName === 'Write') {
const filePath = toolInput.file_path || ''; const filePath = toolInput.file_path || '';
if (!filePath) { if (!filePath || isClaudeSettingsPath(filePath)) {
return rawInput; // allow return rawInput; // allow
} }
@@ -230,7 +361,7 @@ function run(rawInput) {
const edits = toolInput.edits || []; const edits = toolInput.edits || [];
for (const edit of edits) { for (const edit of edits) {
const filePath = edit.file_path || ''; const filePath = edit.file_path || '';
if (filePath && !isChecked(filePath)) { if (filePath && !isClaudeSettingsPath(filePath) && !isChecked(filePath)) {
markChecked(filePath); markChecked(filePath);
return denyResult(editGateMsg(filePath)); return denyResult(editGateMsg(filePath));
} }
@@ -240,6 +371,9 @@ function run(rawInput) {
if (toolName === 'Bash') { if (toolName === 'Bash') {
const command = toolInput.command || ''; const command = toolInput.command || '';
if (isReadOnlyGitIntrospection(command)) {
return rawInput;
}
if (DESTRUCTIVE_BASH.test(command)) { if (DESTRUCTIVE_BASH.test(command)) {
// Gate destructive commands on first attempt; allow retry after facts presented // Gate destructive commands on first attempt; allow retry after facts presented

View File

@@ -0,0 +1,153 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
function readStdinRaw() {
try {
return fs.readFileSync(0, 'utf8');
} catch (_error) {
return '';
}
}
function writeStderr(stderr) {
if (typeof stderr === 'string' && stderr.length > 0) {
process.stderr.write(stderr);
}
}
function passthrough(raw, result) {
const stdout = typeof result?.stdout === 'string' ? result.stdout : '';
if (stdout) {
process.stdout.write(stdout);
return;
}
if (!Number.isInteger(result?.status) || result.status === 0) {
process.stdout.write(raw);
}
}
function resolveTarget(rootDir, relPath) {
const resolvedRoot = path.resolve(rootDir);
const resolvedTarget = path.resolve(rootDir, relPath);
if (
resolvedTarget !== resolvedRoot &&
!resolvedTarget.startsWith(resolvedRoot + path.sep)
) {
throw new Error(`Path traversal rejected: ${relPath}`);
}
return resolvedTarget;
}
function findShellBinary() {
const candidates = [];
if (process.env.BASH && process.env.BASH.trim()) {
candidates.push(process.env.BASH.trim());
}
if (process.platform === 'win32') {
candidates.push('bash.exe', 'bash');
} else {
candidates.push('bash', 'sh');
}
for (const candidate of candidates) {
const probe = spawnSync(candidate, ['-c', ':'], {
stdio: 'ignore',
windowsHide: true,
});
if (!probe.error) {
return candidate;
}
}
return null;
}
function spawnNode(rootDir, relPath, raw, args) {
return spawnSync(process.execPath, [resolveTarget(rootDir, relPath), ...args], {
input: raw,
encoding: 'utf8',
env: {
...process.env,
CLAUDE_PLUGIN_ROOT: rootDir,
ECC_PLUGIN_ROOT: rootDir,
},
cwd: process.cwd(),
timeout: 30000,
windowsHide: true,
});
}
function spawnShell(rootDir, relPath, raw, args) {
const shell = findShellBinary();
if (!shell) {
return {
status: 0,
stdout: '',
stderr: '[Hook] shell runtime unavailable; skipping shell-backed hook\n',
};
}
return spawnSync(shell, [resolveTarget(rootDir, relPath), ...args], {
input: raw,
encoding: 'utf8',
env: {
...process.env,
CLAUDE_PLUGIN_ROOT: rootDir,
ECC_PLUGIN_ROOT: rootDir,
},
cwd: process.cwd(),
timeout: 30000,
windowsHide: true,
});
}
function main() {
const [, , mode, relPath, ...args] = process.argv;
const raw = readStdinRaw();
const rootDir = process.env.CLAUDE_PLUGIN_ROOT || process.env.ECC_PLUGIN_ROOT;
if (!mode || !relPath || !rootDir) {
process.stdout.write(raw);
process.exit(0);
}
let result;
try {
if (mode === 'node') {
result = spawnNode(rootDir, relPath, raw, args);
} else if (mode === 'shell') {
result = spawnShell(rootDir, relPath, raw, args);
} else {
writeStderr(`[Hook] unknown bootstrap mode: ${mode}\n`);
process.stdout.write(raw);
process.exit(0);
}
} catch (error) {
writeStderr(`[Hook] bootstrap resolution failed: ${error.message}\n`);
process.stdout.write(raw);
process.exit(0);
}
passthrough(raw, result);
writeStderr(result.stderr);
if (result.error || result.signal || result.status === null) {
const reason = result.error
? result.error.message
: result.signal
? `terminated by signal ${result.signal}`
: 'missing exit status';
writeStderr(`[Hook] bootstrap execution failed: ${reason}\n`);
process.exit(0);
}
process.exit(Number.isInteger(result.status) ? result.status : 0);
}
main();

View File

@@ -4,24 +4,46 @@
const MAX_STDIN = 1024 * 1024; const MAX_STDIN = 1024 * 1024;
let raw = ''; let raw = '';
process.stdin.setEncoding('utf8'); function run(rawInput) {
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
try { try {
const input = JSON.parse(raw); const input = typeof rawInput === 'string' ? JSON.parse(rawInput) : rawInput;
const cmd = String(input.tool_input?.command || ''); const cmd = String(input.tool_input?.command || '');
if (/(npm run build|pnpm build|yarn build)/.test(cmd)) { if (/(npm run build|pnpm build|yarn build)/.test(cmd)) {
console.error('[Hook] Build completed - async analysis running in background'); return {
stdout: typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput),
stderr: '[Hook] Build completed - async analysis running in background',
exitCode: 0,
};
} }
} catch { } catch {
// ignore parse errors and pass through // ignore parse errors and pass through
} }
process.stdout.write(raw); return typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput);
}); }
if (require.main === module) {
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
const result = run(raw);
if (result && typeof result === 'object') {
if (result.stderr) {
process.stderr.write(`${result.stderr}\n`);
}
process.stdout.write(String(result.stdout || ''));
process.exitCode = Number.isInteger(result.exitCode) ? result.exitCode : 0;
return;
}
process.stdout.write(String(result));
});
}
module.exports = { run };

View File

@@ -38,8 +38,24 @@ function appendLine(filePath, line) {
fs.appendFileSync(filePath, `${line}\n`, 'utf8'); fs.appendFileSync(filePath, `${line}\n`, 'utf8');
} }
function run(rawInput, mode = 'audit') {
const config = MODE_CONFIG[mode];
try {
if (config) {
const input = String(rawInput || '').trim() ? JSON.parse(String(rawInput)) : {};
const command = sanitizeCommand(input.tool_input?.command || '?');
appendLine(path.join(os.homedir(), '.claude', config.fileName), config.format(command));
}
} catch {
// Logging must never block the calling hook.
}
return typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput);
}
function main() { function main() {
const config = MODE_CONFIG[process.argv[2]]; const mode = process.argv[2];
process.stdin.setEncoding('utf8'); process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => { process.stdin.on('data', chunk => {
@@ -50,17 +66,7 @@ function main() {
}); });
process.stdin.on('end', () => { process.stdin.on('end', () => {
try { process.stdout.write(run(raw, mode));
if (config) {
const input = raw.trim() ? JSON.parse(raw) : {};
const command = sanitizeCommand(input.tool_input?.command || '?');
appendLine(path.join(os.homedir(), '.claude', config.fileName), config.format(command));
}
} catch {
// Logging must never block the calling hook.
}
process.stdout.write(raw);
}); });
} }
@@ -69,5 +75,6 @@ if (require.main === module) {
} }
module.exports = { module.exports = {
run,
sanitizeCommand, sanitizeCommand,
}; };

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env node
'use strict';
const { runPostBash } = require('./bash-hook-dispatcher');
let raw = '';
const MAX_STDIN = 1024 * 1024;
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
const result = runPostBash(raw);
if (result.stderr) {
process.stderr.write(result.stderr);
}
process.stdout.write(result.output);
process.exitCode = result.exitCode;
});

View File

@@ -4,17 +4,9 @@
const MAX_STDIN = 1024 * 1024; const MAX_STDIN = 1024 * 1024;
let raw = ''; let raw = '';
process.stdin.setEncoding('utf8'); function run(rawInput) {
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
try { try {
const input = JSON.parse(raw); const input = typeof rawInput === 'string' ? JSON.parse(rawInput) : rawInput;
const cmd = String(input.tool_input?.command || ''); const cmd = String(input.tool_input?.command || '');
if (/\bgh\s+pr\s+create\b/.test(cmd)) { if (/\bgh\s+pr\s+create\b/.test(cmd)) {
@@ -24,13 +16,45 @@ process.stdin.on('end', () => {
const prUrl = match[0]; const prUrl = match[0];
const repo = prUrl.replace(/https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/\d+/, '$1'); const repo = prUrl.replace(/https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/\d+/, '$1');
const prNum = prUrl.replace(/.+\/pull\/(\d+)/, '$1'); const prNum = prUrl.replace(/.+\/pull\/(\d+)/, '$1');
console.error(`[Hook] PR created: ${prUrl}`); return {
console.error(`[Hook] To review: gh pr review ${prNum} --repo ${repo}`); stdout: typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput),
stderr: [
`[Hook] PR created: ${prUrl}`,
`[Hook] To review: gh pr review ${prNum} --repo ${repo}`,
].join('\n'),
exitCode: 0,
};
} }
} }
} catch { } catch {
// ignore parse errors and pass through // ignore parse errors and pass through
} }
process.stdout.write(raw); return typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput);
}); }
if (require.main === module) {
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
const result = run(raw);
if (result && typeof result === 'object') {
if (result.stderr) {
process.stderr.write(`${result.stderr}\n`);
}
process.stdout.write(String(result.stdout || ''));
process.exit(Number.isInteger(result.exitCode) ? result.exitCode : 0);
return;
}
process.stdout.write(String(result));
});
}
module.exports = { run };

View File

@@ -380,7 +380,11 @@ function evaluate(rawInput) {
} }
function run(rawInput) { function run(rawInput) {
return evaluate(rawInput).output; const result = evaluate(rawInput);
return {
stdout: result.output,
exitCode: result.exitCode,
};
} }
// ── stdin entry point ──────────────────────────────────────────── // ── stdin entry point ────────────────────────────────────────────

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env node
'use strict';
const { runPreBash } = require('./bash-hook-dispatcher');
let raw = '';
const MAX_STDIN = 1024 * 1024;
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
const result = runPreBash(raw);
if (result.stderr) {
process.stderr.write(result.stderr);
}
process.stdout.write(result.output);
process.exitCode = result.exitCode;
});

View File

@@ -4,25 +4,49 @@
const MAX_STDIN = 1024 * 1024; const MAX_STDIN = 1024 * 1024;
let raw = ''; let raw = '';
process.stdin.setEncoding('utf8'); function run(rawInput) {
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
try { try {
const input = JSON.parse(raw); const input = typeof rawInput === 'string' ? JSON.parse(rawInput) : rawInput;
const cmd = String(input.tool_input?.command || ''); const cmd = String(input.tool_input?.command || '');
if (/\bgit\s+push\b/.test(cmd)) { if (/\bgit\s+push\b/.test(cmd)) {
console.error('[Hook] Review changes before push...'); return {
console.error('[Hook] Continuing with push (remove this hook to add interactive review)'); stdout: typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput),
stderr: [
'[Hook] Review changes before push...',
'[Hook] Continuing with push (remove this hook to add interactive review)',
].join('\n'),
exitCode: 0,
};
} }
} catch { } catch {
// ignore parse errors and pass through // ignore parse errors and pass through
} }
process.stdout.write(raw); return typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput);
}); }
if (require.main === module) {
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
const result = run(raw);
if (result && typeof result === 'object') {
if (result.stderr) {
process.stderr.write(`${result.stderr}\n`);
}
process.stdout.write(String(result.stdout || ''));
process.exitCode = Number.isInteger(result.exitCode) ? result.exitCode : 0;
return;
}
process.stdout.write(String(result));
});
}
module.exports = { run };

View File

@@ -4,17 +4,9 @@
const MAX_STDIN = 1024 * 1024; const MAX_STDIN = 1024 * 1024;
let raw = ''; let raw = '';
process.stdin.setEncoding('utf8'); function run(rawInput) {
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
try { try {
const input = JSON.parse(raw); const input = typeof rawInput === 'string' ? JSON.parse(rawInput) : rawInput;
const cmd = String(input.tool_input?.command || ''); const cmd = String(input.tool_input?.command || '');
if ( if (
@@ -22,12 +14,44 @@ process.stdin.on('end', () => {
!process.env.TMUX && !process.env.TMUX &&
/(npm (install|test)|pnpm (install|test)|yarn (install|test)?|bun (install|test)|cargo build|make\b|docker\b|pytest|vitest|playwright)/.test(cmd) /(npm (install|test)|pnpm (install|test)|yarn (install|test)?|bun (install|test)|cargo build|make\b|docker\b|pytest|vitest|playwright)/.test(cmd)
) { ) {
console.error('[Hook] Consider running in tmux for session persistence'); return {
console.error('[Hook] tmux new -s dev | tmux attach -t dev'); stdout: typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput),
stderr: [
'[Hook] Consider running in tmux for session persistence',
'[Hook] tmux new -s dev | tmux attach -t dev',
].join('\n'),
exitCode: 0,
};
} }
} catch { } catch {
// ignore parse errors and pass through // ignore parse errors and pass through
} }
process.stdout.write(raw); return typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput);
}); }
if (require.main === module) {
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
const result = run(raw);
if (result && typeof result === 'object') {
if (result.stderr) {
process.stderr.write(`${result.stderr}\n`);
}
process.stdout.write(String(result.stdout || ''));
process.exitCode = Number.isInteger(result.exitCode) ? result.exitCode : 0;
return;
}
process.stdout.write(String(result));
});
}
module.exports = { run };

View File

@@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""
Runtime helpers for ecc_dashboard.py that do not depend on tkinter.
"""
from __future__ import annotations
import os
import platform
import subprocess
from typing import Optional, Tuple, Dict, List
def maximize_window(window) -> None:
"""Maximize the dashboard window using the safest supported method."""
try:
window.state('zoomed')
return
except Exception:
pass
system_name = platform.system()
if system_name == 'Linux':
try:
window.attributes('-zoomed', True)
except Exception:
pass
elif system_name == 'Darwin':
try:
window.attributes('-fullscreen', True)
except Exception:
pass
def build_terminal_launch(
path: str,
*,
os_name: Optional[str] = None,
system_name: Optional[str] = None,
) -> Tuple[List[str], Dict[str, object]]:
"""Return safe argv/kwargs for opening a terminal rooted at the requested path."""
resolved_os_name = os_name or os.name
resolved_system_name = system_name or platform.system()
if resolved_os_name == 'nt':
creationflags = getattr(subprocess, 'CREATE_NEW_CONSOLE', 0)
return (
['cmd.exe', '/k', 'cd', '/d', path],
{
'cwd': path,
'creationflags': creationflags,
},
)
if resolved_system_name == 'Darwin':
return (['open', '-a', 'Terminal', path], {})
return (
['x-terminal-emulator', '-e', 'bash', '-lc', 'cd -- "$1"; exec bash', 'bash', path],
{},
)

View File

@@ -53,11 +53,11 @@ module.exports = createInstallTargetAdapter({
})); }));
}).sort((left, right) => { }).sort((left, right) => {
const getPriority = value => { const getPriority = value => {
if (value === 'rules') { if (value === '.cursor') {
return 0; return 0;
} }
if (value === '.cursor') { if (value === 'rules') {
return 1; return 1;
} }

View File

@@ -18,7 +18,7 @@ An interactive, step-by-step installation wizard for the Everything Claude Code
## Prerequisites ## Prerequisites
This skill must be accessible to Claude Code before activation. Two ways to bootstrap: This skill must be accessible to Claude Code before activation. Two ways to bootstrap:
1. **Via Plugin**: `/plugin install ecc@ecc` — the plugin loads this skill automatically 1. **Via Plugin**: `/plugin install everything-claude-code` — the plugin loads this skill automatically
2. **Manual**: Copy only this skill to `~/.claude/skills/configure-ecc/SKILL.md`, then activate by saying "configure ecc" 2. **Manual**: Copy only this skill to `~/.claude/skills/configure-ecc/SKILL.md`, then activate by saying "configure ecc"
--- ---

View File

@@ -0,0 +1,114 @@
/**
* Tests for consolidated Bash hook dispatchers.
*/
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const preDispatcher = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'pre-bash-dispatcher.js');
const postDispatcher = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'post-bash-dispatcher.js');
function test(name, fn) {
try {
fn();
console.log(`${name}`);
return true;
} catch (error) {
console.log(`${name}`);
console.log(` Error: ${error.message}`);
return false;
}
}
function runScript(scriptPath, input, env = {}) {
return spawnSync('node', [scriptPath], {
input: typeof input === 'string' ? input : JSON.stringify(input),
encoding: 'utf8',
env: {
...process.env,
...env,
},
timeout: 10000,
});
}
function runTests() {
console.log('\n=== Testing Bash hook dispatchers ===\n');
let passed = 0;
let failed = 0;
if (test('pre dispatcher blocks --no-verify before other Bash checks', () => {
const input = { tool_input: { command: 'git commit --no-verify -m "x"' } };
const result = runScript(preDispatcher, input, { ECC_HOOK_PROFILE: 'strict' });
assert.strictEqual(result.status, 2, 'Expected dispatcher to block git hook bypass');
assert.ok(result.stderr.includes('--no-verify'), 'Expected block-no-verify reason in stderr');
assert.strictEqual(result.stdout, '', 'Blocking hook should not pass through stdout');
})) passed++; else failed++;
if (test('pre dispatcher still honors per-hook disable flags', () => {
const input = { tool_input: { command: 'git push origin main' } };
const enabled = runScript(preDispatcher, input, { ECC_HOOK_PROFILE: 'strict' });
assert.strictEqual(enabled.status, 0);
assert.ok(enabled.stderr.includes('Review changes before push'), 'Expected git push reminder when enabled');
const disabled = runScript(preDispatcher, input, {
ECC_HOOK_PROFILE: 'strict',
ECC_DISABLED_HOOKS: 'pre:bash:git-push-reminder',
});
assert.strictEqual(disabled.status, 0);
assert.ok(!disabled.stderr.includes('Review changes before push'), 'Disabled hook should not emit reminder');
})) passed++; else failed++;
if (test('pre dispatcher respects hook profiles inside the consolidated path', () => {
const input = { tool_input: { command: 'git push origin main' } };
const result = runScript(preDispatcher, input, { ECC_HOOK_PROFILE: 'minimal' });
assert.strictEqual(result.status, 0);
assert.strictEqual(result.stderr, '', 'Strict-only reminders should stay disabled in minimal profile');
assert.strictEqual(result.stdout, JSON.stringify(input));
})) passed++; else failed++;
if (test('post dispatcher writes both bash audit and cost logs in one pass', () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-bash-dispatcher-'));
const payload = { tool_input: { command: 'npm publish --token fixture-token' } };
try {
const result = runScript(postDispatcher, payload, {
HOME: homeDir,
USERPROFILE: homeDir,
});
assert.strictEqual(result.status, 0);
assert.strictEqual(result.stdout, JSON.stringify(payload));
const auditLog = fs.readFileSync(path.join(homeDir, '.claude', 'bash-commands.log'), 'utf8');
const costLog = fs.readFileSync(path.join(homeDir, '.claude', 'cost-tracker.log'), 'utf8');
assert.ok(auditLog.includes('--token=<REDACTED>'));
assert.ok(costLog.includes('tool=Bash command=npm publish --token=<REDACTED>'));
assert.ok(!auditLog.includes('fixture-token'));
assert.ok(!costLog.includes('fixture-token'));
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('post dispatcher preserves PR-created hints after consolidated execution', () => {
const payload = {
tool_input: { command: 'gh pr create --title "Fix bug" --body "desc"' },
tool_output: { output: 'https://github.com/owner/repo/pull/42\n' },
};
const result = runScript(postDispatcher, payload);
assert.strictEqual(result.status, 0);
assert.ok(result.stderr.includes('PR created: https://github.com/owner/repo/pull/42'));
assert.ok(result.stderr.includes('gh pr review 42 --repo owner/repo'));
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
runTests();

View File

@@ -10,10 +10,12 @@ const { spawnSync } = require('child_process');
const runner = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'run-with-flags.js'); const runner = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'run-with-flags.js');
const externalStateDir = process.env.GATEGUARD_STATE_DIR; const externalStateDir = process.env.GATEGUARD_STATE_DIR;
const tmpRoot = process.env.TMPDIR || process.env.TEMP || process.env.TMP || '/tmp'; const tmpRoot = process.env.TMPDIR || process.env.TEMP || process.env.TMP || '/tmp';
const stateDir = externalStateDir || fs.mkdtempSync(path.join(tmpRoot, 'gateguard-test-')); const baseStateDir = externalStateDir || tmpRoot;
const stateDir = fs.mkdtempSync(path.join(baseStateDir, 'gateguard-test-'));
// Use a fixed session ID so test process and spawned hook process share the same state file // Use a fixed session ID so test process and spawned hook process share the same state file
const TEST_SESSION_ID = 'gateguard-test-session'; const TEST_SESSION_ID = 'gateguard-test-session';
const stateFile = path.join(stateDir, `state-${TEST_SESSION_ID}.json`); const stateFile = path.join(stateDir, `state-${TEST_SESSION_ID}.json`);
const READ_HEARTBEAT_MS = 60 * 1000;
function test(name, fn) { function test(name, fn) {
try { try {
@@ -29,11 +31,12 @@ function test(name, fn) {
function clearState() { function clearState() {
try { try {
if (fs.existsSync(stateFile)) { if (fs.existsSync(stateDir)) {
fs.unlinkSync(stateFile); fs.rmSync(stateDir, { recursive: true, force: true });
} }
fs.mkdirSync(stateDir, { recursive: true });
} catch (err) { } catch (err) {
console.error(` [clearState] failed to remove ${stateFile}: ${err.message}`); console.error(` [clearState] failed to remove state files in ${stateDir}: ${err.message}`);
} }
} }
@@ -363,18 +366,45 @@ function runTests() {
} }
})) passed++; else failed++; })) passed++; else failed++;
// --- Test 12: reads refresh active session state --- // --- Test 12: hot-path reads do not rewrite state within heartbeat ---
clearState(); clearState();
if (test('touches last_active on read so active sessions do not age out', () => { if (test('does not rewrite state on hot-path reads within heartbeat window', () => {
const staleButActive = Date.now() - (29 * 60 * 1000); const recentlyActive = Date.now() - (READ_HEARTBEAT_MS - 10 * 1000);
writeState({
checked: ['/src/keep-alive.js'],
last_active: recentlyActive
});
const beforeStat = fs.statSync(stateFile);
const before = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
assert.strictEqual(before.last_active, recentlyActive, 'seed state should use the expected timestamp');
const result = runHook({
tool_name: 'Edit',
tool_input: { file_path: '/src/keep-alive.js', old_string: 'a', new_string: 'b' }
});
const output = parseOutput(result.stdout);
assert.ok(output, 'should produce valid JSON output');
if (output.hookSpecificOutput) {
assert.notStrictEqual(output.hookSpecificOutput.permissionDecision, 'deny',
'already-checked file should still be allowed');
}
const afterStat = fs.statSync(stateFile);
const after = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
assert.strictEqual(after.last_active, recentlyActive, 'read should not touch last_active within heartbeat');
assert.strictEqual(afterStat.mtimeMs, beforeStat.mtimeMs, 'read should not rewrite the state file within heartbeat');
})) passed++; else failed++;
// --- Test 13: reads refresh stale active state after heartbeat ---
clearState();
if (test('refreshes last_active after heartbeat elapses', () => {
const staleButActive = Date.now() - (READ_HEARTBEAT_MS + 5 * 1000);
writeState({ writeState({
checked: ['/src/keep-alive.js'], checked: ['/src/keep-alive.js'],
last_active: staleButActive last_active: staleButActive
}); });
const before = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
assert.strictEqual(before.last_active, staleButActive, 'seed state should use the expected timestamp');
const result = runHook({ const result = runHook({
tool_name: 'Edit', tool_name: 'Edit',
tool_input: { file_path: '/src/keep-alive.js', old_string: 'a', new_string: 'b' } tool_input: { file_path: '/src/keep-alive.js', old_string: 'a', new_string: 'b' }
@@ -387,10 +417,10 @@ function runTests() {
} }
const after = JSON.parse(fs.readFileSync(stateFile, 'utf8')); const after = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
assert.ok(after.last_active > staleButActive, 'successful reads should refresh last_active'); assert.ok(after.last_active > staleButActive, 'read should refresh last_active after heartbeat');
})) passed++; else failed++; })) passed++; else failed++;
// --- Test 13: pruning preserves routine bash gate marker --- // --- Test 14: pruning preserves routine bash gate marker ---
clearState(); clearState();
if (test('preserves __bash_session__ when pruning oversized state', () => { if (test('preserves __bash_session__ when pruning oversized state', () => {
const checked = ['__bash_session__']; const checked = ['__bash_session__'];
@@ -419,15 +449,126 @@ function runTests() {
assert.ok(persisted.checked.length <= 500, 'pruned state should still honor the checked-entry cap'); assert.ok(persisted.checked.length <= 500, 'pruned state should still honor the checked-entry cap');
})) passed++; else failed++; })) passed++; else failed++;
// Cleanup only the temp directory created by this test file. // --- Test 15: raw input session IDs provide stable retry state without env vars ---
if (!externalStateDir) { clearState();
try { if (test('uses raw input session_id when hook env vars are missing', () => {
if (fs.existsSync(stateDir)) { const input = {
fs.rmSync(stateDir, { recursive: true, force: true }); session_id: 'raw-session-1234',
} tool_name: 'Bash',
} catch (err) { tool_input: { command: 'ls -la' }
console.error(` [cleanup] failed to remove ${stateDir}: ${err.message}`); };
const first = runBashHook(input, {
CLAUDE_SESSION_ID: '',
ECC_SESSION_ID: '',
});
const firstOutput = parseOutput(first.stdout);
assert.strictEqual(firstOutput.hookSpecificOutput.permissionDecision, 'deny');
const second = runBashHook(input, {
CLAUDE_SESSION_ID: '',
ECC_SESSION_ID: '',
});
const secondOutput = parseOutput(second.stdout);
if (secondOutput.hookSpecificOutput) {
assert.notStrictEqual(secondOutput.hookSpecificOutput.permissionDecision, 'deny',
'retry should be allowed when raw session_id is stable');
} else {
assert.strictEqual(secondOutput.tool_name, 'Bash');
} }
})) passed++; else failed++;
// --- Test 16: allows Claude settings edits so the hook can be disabled safely ---
clearState();
if (test('allows edits to .claude/settings.json without gating', () => {
const input = {
tool_name: 'Edit',
tool_input: { file_path: '/workspace/app/.claude/settings.json', old_string: '{}', new_string: '{"hooks":[]}' }
};
const result = runHook(input);
const output = parseOutput(result.stdout);
assert.ok(output, 'should produce valid JSON output');
if (output.hookSpecificOutput) {
assert.notStrictEqual(output.hookSpecificOutput.permissionDecision, 'deny',
'settings edits must not be blocked by gateguard');
} else {
assert.strictEqual(output.tool_name, 'Edit');
}
})) passed++; else failed++;
// --- Test 17: allows read-only git introspection without first-bash gating ---
clearState();
if (test('allows read-only git status without first-bash gating', () => {
const input = {
tool_name: 'Bash',
tool_input: { command: 'git status --short' }
};
const result = runBashHook(input);
const output = parseOutput(result.stdout);
assert.ok(output, 'should produce valid JSON output');
if (output.hookSpecificOutput) {
assert.notStrictEqual(output.hookSpecificOutput.permissionDecision, 'deny',
'read-only git introspection should not be blocked');
} else {
assert.strictEqual(output.tool_name, 'Bash');
}
})) passed++; else failed++;
// --- Test 18: rejects mutating git commands that only share a prefix ---
clearState();
if (test('does not treat mutating git commands as read-only introspection', () => {
const input = {
tool_name: 'Bash',
tool_input: { command: 'git status && rm -rf /tmp/demo' }
};
const result = runBashHook(input);
const output = parseOutput(result.stdout);
assert.ok(output, 'should produce valid JSON output');
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('current instruction'));
})) passed++; else failed++;
// --- Test 19: long raw session IDs hash instead of collapsing to project fallback ---
clearState();
if (test('uses a stable hash for long raw session ids', () => {
const longSessionId = `session-${'x'.repeat(120)}`;
const input = {
session_id: longSessionId,
tool_name: 'Bash',
tool_input: { command: 'ls -la' }
};
const first = runBashHook(input, {
CLAUDE_SESSION_ID: '',
ECC_SESSION_ID: '',
});
const firstOutput = parseOutput(first.stdout);
assert.strictEqual(firstOutput.hookSpecificOutput.permissionDecision, 'deny');
const stateFiles = fs.readdirSync(stateDir).filter(entry => entry.startsWith('state-') && entry.endsWith('.json'));
assert.strictEqual(stateFiles.length, 1, 'long raw session id should still produce a dedicated state file');
assert.ok(/state-sid-[a-f0-9]{24}\.json$/.test(stateFiles[0]), 'long raw session ids should hash to a bounded sid-* key');
const second = runBashHook(input, {
CLAUDE_SESSION_ID: '',
ECC_SESSION_ID: '',
});
const secondOutput = parseOutput(second.stdout);
if (secondOutput.hookSpecificOutput) {
assert.notStrictEqual(secondOutput.hookSpecificOutput.permissionDecision, 'deny',
'retry should be allowed when long raw session_id is stable');
} else {
assert.strictEqual(secondOutput.tool_name, 'Bash');
}
})) passed++; else failed++;
// Cleanup only the temp directory created by this test file.
try {
if (fs.existsSync(stateDir)) {
fs.rmSync(stateDir, { recursive: true, force: true });
}
} catch (err) {
console.error(` [cleanup] failed to remove ${stateDir}: ${err.message}`);
} }
console.log(`\n ${passed} passed, ${failed} failed\n`); console.log(`\n ${passed} passed, ${failed} failed\n`);

View File

@@ -1888,6 +1888,33 @@ async function runTests() {
passed++; passed++;
else failed++; else failed++;
if (
test('hooks.json consolidates Bash hooks into one pre and one post dispatcher', () => {
const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json');
const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8'));
const preBash = hooks.hooks.PreToolUse.filter(entry => entry.matcher === 'Bash');
const postBash = hooks.hooks.PostToolUse.filter(entry => entry.matcher === 'Bash');
assert.strictEqual(preBash.length, 1, 'Should have exactly one PreToolUse Bash dispatcher');
assert.strictEqual(postBash.length, 1, 'Should have exactly one PostToolUse Bash dispatcher');
assert.strictEqual(preBash[0].id, 'pre:bash:dispatcher');
assert.strictEqual(postBash[0].id, 'post:bash:dispatcher');
const preCommand = Array.isArray(preBash[0].hooks[0].command)
? preBash[0].hooks[0].command.join(' ')
: preBash[0].hooks[0].command;
const postCommand = Array.isArray(postBash[0].hooks[0].command)
? postBash[0].hooks[0].command.join(' ')
: postBash[0].hooks[0].command;
assert.ok(preCommand.includes('pre-bash-dispatcher.js'), 'PreToolUse Bash hook should use the pre dispatcher');
assert.ok(postCommand.includes('post-bash-dispatcher.js'), 'PostToolUse Bash hook should use the post dispatcher');
})
)
passed++;
else failed++;
if ( if (
test('SessionEnd marker hook is async and cleanup-safe', () => { test('SessionEnd marker hook is async and cleanup-safe', () => {
const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json'); const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json');
@@ -1912,13 +1939,14 @@ async function runTests() {
for (const entry of hookArray) { for (const entry of hookArray) {
for (const hook of entry.hooks) { for (const hook of entry.hooks) {
if (hook.type === 'command') { if (hook.type === 'command') {
const isNode = hook.command.startsWith('node'); const commandText = Array.isArray(hook.command) ? hook.command.join(' ') : hook.command;
const isNpx = hook.command.startsWith('npx '); const commandStart = Array.isArray(hook.command) ? hook.command[0] : hook.command;
const isSkillScript = hook.command.includes('/skills/') && (/^(bash|sh)\s/.test(hook.command) || hook.command.startsWith('${CLAUDE_PLUGIN_ROOT}/skills/')); const isNode = commandStart === 'node' || (typeof commandStart === 'string' && commandStart.startsWith('node'));
const isHookShellWrapper = /^(bash|sh)\s+["']?\$\{CLAUDE_PLUGIN_ROOT\}\/scripts\/hooks\/run-with-flags-shell\.sh/.test(hook.command); const isNpx = commandStart === 'npx' || (typeof commandStart === 'string' && commandStart.startsWith('npx '));
const isSkillScript = commandText.includes('/skills/') && (/^(bash|sh)\s/.test(commandText) || commandText.includes('/skills/'));
assert.ok( assert.ok(
isNode || isNpx || isSkillScript || isHookShellWrapper, isNode || isNpx || isSkillScript,
`Hook command should use node or approved shell wrapper: ${hook.command.substring(0, 100)}...` `Hook command should use node or approved shell wrapper: ${commandText.substring(0, 100)}...`
); );
} }
} }
@@ -1940,16 +1968,18 @@ async function runTests() {
const sessionStartHook = hooks.hooks.SessionStart?.[0]?.hooks?.[0]; const sessionStartHook = hooks.hooks.SessionStart?.[0]?.hooks?.[0];
assert.ok(sessionStartHook, 'Should define a SessionStart hook'); assert.ok(sessionStartHook, 'Should define a SessionStart hook');
// The bootstrap was extracted to a standalone file to avoid shell history const commandText = Array.isArray(sessionStartHook.command)
// expansion of `!` characters that caused startup hook errors when the ? sessionStartHook.command.join(' ')
// logic was embedded as an inline `node -e "..."` string. : sessionStartHook.command;
assert.ok(Array.isArray(sessionStartHook.command), 'SessionStart should use argv form for cross-platform safety');
assert.ok( assert.ok(
sessionStartHook.command.includes('session-start-bootstrap.js'), commandText.includes('session-start-bootstrap.js'),
'SessionStart should delegate to the extracted bootstrap script' 'SessionStart should delegate to the extracted bootstrap script'
); );
assert.ok(sessionStartHook.command.includes('CLAUDE_PLUGIN_ROOT'), 'SessionStart should use CLAUDE_PLUGIN_ROOT'); assert.ok(commandText.includes('CLAUDE_PLUGIN_ROOT'), 'SessionStart should use CLAUDE_PLUGIN_ROOT');
assert.ok(!sessionStartHook.command.includes('find '), 'Should not scan arbitrary plugin paths with find'); assert.ok(!commandText.includes('${CLAUDE_PLUGIN_ROOT}'), 'SessionStart should not depend on raw shell placeholder expansion');
assert.ok(!sessionStartHook.command.includes('head -n 1'), 'Should not pick the first matching plugin path'); assert.ok(!commandText.includes('find '), 'Should not scan arbitrary plugin paths with find');
assert.ok(!commandText.includes('head -n 1'), 'Should not pick the first matching plugin path');
// Verify the bootstrap script itself contains the expected logic // Verify the bootstrap script itself contains the expected logic
const bootstrapPath = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'session-start-bootstrap.js'); const bootstrapPath = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'session-start-bootstrap.js');
@@ -1971,29 +2001,41 @@ async function runTests() {
const sessionEndHooks = (hooks.hooks.SessionEnd || []).flatMap(entry => entry.hooks || []); const sessionEndHooks = (hooks.hooks.SessionEnd || []).flatMap(entry => entry.hooks || []);
for (const hook of [...stopHooks, ...sessionEndHooks]) { for (const hook of [...stopHooks, ...sessionEndHooks]) {
assert.ok(hook.command.startsWith('node -e "'), 'Lifecycle hook should use inline node resolver'); const commandText = Array.isArray(hook.command) ? hook.command.join(' ') : hook.command;
assert.ok(hook.command.includes('run-with-flags.js'), 'Lifecycle hook should resolve the runner script'); assert.ok(
assert.ok(hook.command.includes('CLAUDE_PLUGIN_ROOT'), 'Lifecycle hook should consult CLAUDE_PLUGIN_ROOT'); (Array.isArray(hook.command) && hook.command[0] === 'node' && hook.command[1] === '-e') ||
assert.ok(hook.command.includes('plugins'), 'Lifecycle hook should probe known plugin roots'); (typeof hook.command === 'string' && hook.command.startsWith('node -e "')),
assert.ok(!hook.command.includes('find '), 'Lifecycle hook should not scan arbitrary plugin paths with find'); 'Lifecycle hook should use inline node resolver'
assert.ok(!hook.command.includes('head -n 1'), 'Lifecycle hook should not pick the first matching plugin path'); );
assert.ok(commandText.includes('run-with-flags.js'), 'Lifecycle hook should resolve the runner script');
assert.ok(commandText.includes('CLAUDE_PLUGIN_ROOT'), 'Lifecycle hook should consult CLAUDE_PLUGIN_ROOT');
assert.ok(!commandText.includes('${CLAUDE_PLUGIN_ROOT}'), 'Lifecycle hook should not depend on raw shell placeholder expansion');
assert.ok(commandText.includes('plugins'), 'Lifecycle hook should probe known plugin roots');
assert.ok(!commandText.includes('find '), 'Lifecycle hook should not scan arbitrary plugin paths with find');
assert.ok(!commandText.includes('head -n 1'), 'Lifecycle hook should not pick the first matching plugin path');
} }
}) })
) )
passed++; passed++;
else failed++; else failed++;
if ( if (
test('script references use CLAUDE_PLUGIN_ROOT variable or a safe inline resolver', () => { test('script references use the safe inline resolver or plugin bootstrap', () => {
const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json'); const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json');
const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8')); const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8'));
const checkHooks = hookArray => { const checkHooks = hookArray => {
for (const entry of hookArray) { for (const entry of hookArray) {
for (const hook of entry.hooks) { for (const hook of entry.hooks) {
if (hook.type === 'command' && hook.command.includes('scripts/hooks/')) { const commandText = Array.isArray(hook.command) ? hook.command.join(' ') : hook.command;
const usesInlineResolver = hook.command.startsWith('node -e') && hook.command.includes('run-with-flags.js'); const commandStart = Array.isArray(hook.command) ? `${hook.command[0]} ${hook.command[1] || ''}`.trim() : hook.command;
const hasPluginRoot = hook.command.includes('${CLAUDE_PLUGIN_ROOT}') || usesInlineResolver; if (hook.type === 'command' && commandText.includes('scripts/hooks/')) {
assert.ok(hasPluginRoot, `Script paths should use CLAUDE_PLUGIN_ROOT: ${hook.command.substring(0, 80)}...`); const usesInlineResolver = commandStart.startsWith('node -e') && commandText.includes('run-with-flags.js');
const usesPluginBootstrap = commandStart.startsWith('node -e') && commandText.includes('plugin-hook-bootstrap.js');
assert.ok(!commandText.includes('${CLAUDE_PLUGIN_ROOT}'), `Script paths should not depend on raw shell placeholder expansion: ${commandText.substring(0, 80)}...`);
assert.ok(
usesInlineResolver || usesPluginBootstrap,
`Script paths should use the inline resolver or plugin bootstrap: ${commandText.substring(0, 80)}...`
);
} }
} }
} }

View File

@@ -110,24 +110,70 @@ function runHookCommand(command, input = {}, env = {}, timeoutMs = 10000) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const isWindows = process.platform === 'win32'; const isWindows = process.platform === 'win32';
const mergedEnv = { ...process.env, CLAUDE_PLUGIN_ROOT: REPO_ROOT, ...env }; const mergedEnv = { ...process.env, CLAUDE_PLUGIN_ROOT: REPO_ROOT, ...env };
if (Array.isArray(command)) {
const [program, ...args] = command;
const proc = spawn(program, args, { env: mergedEnv, stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
let timer;
proc.stdout.on('data', data => stdout += data);
proc.stderr.on('data', data => stderr += data);
proc.stdin.on('error', (err) => {
if (err.code !== 'EPIPE' && err.code !== 'EOF') {
if (timer) clearTimeout(timer);
reject(err);
}
});
if (input && Object.keys(input).length > 0) {
proc.stdin.write(JSON.stringify(input));
}
proc.stdin.end();
timer = setTimeout(() => {
proc.kill(isWindows ? undefined : 'SIGKILL');
reject(new Error(`Hook command timed out after ${timeoutMs}ms`));
}, timeoutMs);
proc.on('close', code => {
clearTimeout(timer);
resolve({ code, stdout, stderr });
});
proc.on('error', err => {
clearTimeout(timer);
reject(err);
});
return;
}
const resolvedCommand = command.replace( const resolvedCommand = command.replace(
/\$\{([A-Z_][A-Z0-9_]*)\}/g, /\$\{([A-Z_][A-Z0-9_]*)\}/g,
(_, name) => String(mergedEnv[name] || '') (_, name) => String(mergedEnv[name] || '')
); );
const nodeMatch = resolvedCommand.match(/^node\s+"([^"]+)"\s*(.*)$/); const inlineNodeMatch = resolvedCommand.match(/^node -e "((?:[^"\\]|\\.)*)"(?:\s+(.*))?$/s);
const useDirectNodeSpawn = Boolean(nodeMatch); const fileNodeMatch = resolvedCommand.match(/^node\s+"([^"]+)"\s*(.*)$/);
const useDirectNodeSpawn = Boolean(inlineNodeMatch || fileNodeMatch);
const shell = isWindows ? 'cmd' : 'bash'; const shell = isWindows ? 'cmd' : 'bash';
const shellArgs = isWindows ? ['/d', '/s', '/c', resolvedCommand] : ['-lc', resolvedCommand]; const shellArgs = isWindows ? ['/d', '/s', '/c', resolvedCommand] : ['-lc', resolvedCommand];
const nodeArgs = nodeMatch const splitArgs = value => Array.from(
? [ String(value || '').matchAll(/"([^"]*)"|(\S+)/g),
nodeMatch[1], m => m[1] !== undefined ? m[1] : m[2]
...Array.from( );
nodeMatch[2].matchAll(/"([^"]*)"|(\S+)/g), const unescapeInlineJs = value => value
m => m[1] !== undefined ? m[1] : m[2] .replace(/\\\\/g, '\\')
) .replace(/\\"/g, '"')
] .replace(/\\n/g, '\n')
: []; .replace(/\\t/g, '\t');
const nodeArgs = inlineNodeMatch
? ['-e', unescapeInlineJs(inlineNodeMatch[1]), ...splitArgs(inlineNodeMatch[2])]
: fileNodeMatch
? [fileNodeMatch[1], ...splitArgs(fileNodeMatch[2])]
: [];
const proc = useDirectNodeSpawn const proc = useDirectNodeSpawn
? spawn('node', nodeArgs, { env: mergedEnv, stdio: ['pipe', 'pipe', 'pipe'] }) ? spawn('node', nodeArgs, { env: mergedEnv, stdio: ['pipe', 'pipe', 'pipe'] })
@@ -210,6 +256,14 @@ function getHookCommandByDescription(hooks, lifecycle, descriptionText) {
return hookGroup.hooks[0].command; return hookGroup.hooks[0].command;
} }
function getHookCommandById(hooks, lifecycle, hookId) {
const hookGroup = hooks.hooks[lifecycle]?.find(entry => entry.id === hookId);
assert.ok(hookGroup, `Expected ${lifecycle} hook with id "${hookId}"`);
assert.ok(hookGroup.hooks?.[0]?.command, `Expected ${lifecycle} hook command for id "${hookId}"`);
return hookGroup.hooks[0].command;
}
// Test suite // Test suite
async function runTests() { async function runTests() {
console.log('\n=== Hook Integration Tests ===\n'); console.log('\n=== Hook Integration Tests ===\n');
@@ -294,12 +348,7 @@ async function runTests() {
})) passed++; else failed++; })) passed++; else failed++;
if (await asyncTest('dev server hook transforms command to tmux session', async () => { if (await asyncTest('dev server hook transforms command to tmux session', async () => {
// Test the auto-tmux dev hook — transforms dev commands to run in tmux const hookCommand = getHookCommandById(hooks, 'PreToolUse', 'pre:bash:dispatcher');
const hookCommand = getHookCommandByDescription(
hooks,
'PreToolUse',
'Auto-start dev servers in tmux'
);
const result = await runHookCommand(hookCommand, { const result = await runHookCommand(hookCommand, {
tool_input: { command: 'npm run dev' } tool_input: { command: 'npm run dev' }
}); });
@@ -480,12 +529,7 @@ async function runTests() {
})) passed++; else failed++; })) passed++; else failed++;
if (await asyncTest('dev server hook transforms yarn dev to tmux session', async () => { if (await asyncTest('dev server hook transforms yarn dev to tmux session', async () => {
// The auto-tmux dev hook transforms dev commands (yarn dev, npm run dev, etc.) const hookCommand = getHookCommandById(hooks, 'PreToolUse', 'pre:bash:dispatcher');
const hookCommand = getHookCommandByDescription(
hooks,
'PreToolUse',
'Auto-start dev servers in tmux'
);
const result = await runHookCommand(hookCommand, { const result = await runHookCommand(hookCommand, {
tool_input: { command: 'yarn dev' } tool_input: { command: 'yarn dev' }
}); });
@@ -617,14 +661,8 @@ async function runTests() {
})) passed++; else failed++; })) passed++; else failed++;
if (await asyncTest('PostToolUse PR hook extracts PR URL', async () => { if (await asyncTest('PostToolUse PR hook extracts PR URL', async () => {
// Find the PR logging hook const hookCommand = getHookCommandById(hooks, 'PostToolUse', 'post:bash:dispatcher');
const prHook = hooks.hooks.PostToolUse.find(h => const result = await runHookCommand(hookCommand, {
h.description && h.description.includes('PR URL')
);
assert.ok(prHook, 'PR hook should exist');
const result = await runHookCommand(prHook.hooks[0].command, {
tool_input: { command: 'gh pr create --title "Test"' }, tool_input: { command: 'gh pr create --title "Test"' },
tool_output: { output: 'Creating pull request...\nhttps://github.com/owner/repo/pull/123' } tool_output: { output: 'Creating pull request...\nhttps://github.com/owner/repo/pull/123' }
}); });
@@ -899,16 +937,22 @@ async function runTests() {
assert.ok(asyncHook.hooks[0].timeout > 0, 'Timeout should be positive'); assert.ok(asyncHook.hooks[0].timeout > 0, 'Timeout should be positive');
const command = asyncHook.hooks[0].command; const command = asyncHook.hooks[0].command;
const isNodeInline = command.startsWith('node -e'); const commandText = Array.isArray(command) ? command.join(' ') : command;
const isNodeScript = command.startsWith('node "'); const isNodeInline =
(Array.isArray(command) && command[0] === 'node' && command[1] === '-e') ||
commandText.startsWith('node -e');
const isNodeScript =
(Array.isArray(command) && command[0] === 'node' && typeof command[1] === 'string' && command[1].endsWith('.js')) ||
commandText.startsWith('node "');
const isShellWrapper = const isShellWrapper =
command.startsWith('bash "') || (Array.isArray(command) && (command[0] === 'bash' || command[0] === 'sh')) ||
command.startsWith('sh "') || commandText.startsWith('bash "') ||
command.startsWith('bash -lc ') || commandText.startsWith('sh "') ||
command.startsWith('sh -c '); commandText.startsWith('bash -lc ') ||
commandText.startsWith('sh -c ');
assert.ok( assert.ok(
isNodeInline || isNodeScript || isShellWrapper, isNodeInline || isNodeScript || isShellWrapper,
`Async hook command should be runnable (node -e, node script, or shell wrapper), got: ${command.substring(0, 80)}` `Async hook command should be runnable (node -e, node script, or shell wrapper), got: ${commandText.substring(0, 80)}`
); );
})) passed++; else failed++; })) passed++; else failed++;
@@ -920,19 +964,28 @@ async function runTests() {
for (const hook of hookDef.hooks) { for (const hook of hookDef.hooks) {
assert.ok(hook.command, `Hook in ${hookType} should have command field`); assert.ok(hook.command, `Hook in ${hookType} should have command field`);
const isInline = hook.command.startsWith('node -e'); const command = hook.command;
const isFilePath = hook.command.startsWith('node "'); const commandText = Array.isArray(command) ? command.join(' ') : command;
const isNpx = hook.command.startsWith('npx '); const isInline =
(Array.isArray(command) && command[0] === 'node' && command[1] === '-e') ||
commandText.startsWith('node -e');
const isFilePath =
(Array.isArray(command) && command[0] === 'node' && typeof command[1] === 'string' && command[1].endsWith('.js')) ||
commandText.startsWith('node "');
const isNpx = (Array.isArray(command) && command[0] === 'npx') || commandText.startsWith('npx ');
const isShellWrapper = const isShellWrapper =
hook.command.startsWith('bash "') || (Array.isArray(command) && (command[0] === 'bash' || command[0] === 'sh')) ||
hook.command.startsWith('sh "') || commandText.startsWith('bash "') ||
hook.command.startsWith('bash -lc ') || commandText.startsWith('sh "') ||
hook.command.startsWith('sh -c '); commandText.startsWith('bash -lc ') ||
const isShellScriptPath = hook.command.endsWith('.sh'); commandText.startsWith('sh -c ');
const isShellScriptPath =
(Array.isArray(command) && typeof command[0] === 'string' && command[0].endsWith('.sh')) ||
commandText.endsWith('.sh');
assert.ok( assert.ok(
isInline || isFilePath || isNpx || isShellWrapper || isShellScriptPath, isInline || isFilePath || isNpx || isShellWrapper || isShellScriptPath,
`Hook command in ${hookType} should be node -e, node script, npx, or shell wrapper/script, got: ${hook.command.substring(0, 80)}` `Hook command in ${hookType} should be node -e, node script, npx, or shell wrapper/script, got: ${commandText.substring(0, 80)}`
); );
} }
} }

View File

@@ -124,11 +124,11 @@ function runTests() {
); );
assert.ok( assert.ok(
plan.operations.some(operation => ( plan.operations.some(operation => (
operation.sourceRelativePath === 'rules/common/agents.md' operation.sourceRelativePath === '.cursor/rules/common-agents.md'
&& operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-agents.mdc') && operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-agents.mdc')
&& operation.strategy === 'flatten-copy' && operation.strategy === 'flatten-copy'
)), )),
'Should produce Cursor .mdc rules while preferring rules-core over duplicate platform copies' 'Should produce Cursor .mdc rules while preferring native Cursor platform copies over duplicate rules-core files'
); );
})) passed++; else failed++; })) passed++; else failed++;

View File

@@ -94,14 +94,14 @@ function runTests() {
normalizedRelativePath(operation.sourceRelativePath) === '.cursor/hooks.json' normalizedRelativePath(operation.sourceRelativePath) === '.cursor/hooks.json'
)); ));
const preserved = plan.operations.find(operation => ( const preserved = plan.operations.find(operation => (
normalizedRelativePath(operation.sourceRelativePath) === 'rules/common/coding-style.md' normalizedRelativePath(operation.sourceRelativePath) === '.cursor/rules/common-coding-style.md'
)); ));
assert.ok(hooksJson, 'Should preserve non-rule Cursor platform config files'); assert.ok(hooksJson, 'Should preserve non-rule Cursor platform config files');
assert.strictEqual(hooksJson.strategy, 'preserve-relative-path'); assert.strictEqual(hooksJson.strategy, 'preserve-relative-path');
assert.strictEqual(hooksJson.destinationPath, path.join(projectRoot, '.cursor', 'hooks.json')); assert.strictEqual(hooksJson.destinationPath, path.join(projectRoot, '.cursor', 'hooks.json'));
assert.ok(preserved, 'Should include flattened rules scaffold operations'); assert.ok(preserved, 'Should include flattened Cursor rule scaffold operations');
assert.strictEqual(preserved.strategy, 'flatten-copy'); assert.strictEqual(preserved.strategy, 'flatten-copy');
assert.strictEqual( assert.strictEqual(
preserved.destinationPath, preserved.destinationPath,
@@ -236,8 +236,8 @@ function runTests() {
assert.strictEqual(commonAgentsDestinations.length, 1, 'Should keep only one common-agents.mdc operation'); assert.strictEqual(commonAgentsDestinations.length, 1, 'Should keep only one common-agents.mdc operation');
assert.strictEqual( assert.strictEqual(
normalizedRelativePath(commonAgentsDestinations[0].sourceRelativePath), normalizedRelativePath(commonAgentsDestinations[0].sourceRelativePath),
'rules/common/agents.md', '.cursor/rules/common-agents.md',
'Should prefer rules-core when cursor platform rules would collide' 'Should prefer native .cursor/rules content when cursor platform rules would collide'
); );
})) passed++; else failed++; })) passed++; else failed++;

View File

@@ -79,6 +79,28 @@ function assertSafeRepoRelativePath(relativePath, label) {
); );
} }
function collectMarkdownFiles(rootPath) {
if (!fs.existsSync(rootPath)) {
return [];
}
const stat = fs.statSync(rootPath);
if (stat.isFile()) {
return rootPath.endsWith('.md') ? [rootPath] : [];
}
const files = [];
for (const entry of fs.readdirSync(rootPath, { withFileTypes: true })) {
const nextPath = path.join(rootPath, entry.name);
if (entry.isDirectory()) {
files.push(...collectMarkdownFiles(nextPath));
} else if (entry.isFile() && nextPath.endsWith('.md')) {
files.push(nextPath);
}
}
return files;
}
const rootPackage = loadJsonObject(packageJsonPath, 'package.json'); const rootPackage = loadJsonObject(packageJsonPath, 'package.json');
const packageLock = loadJsonObject(packageLockPath, 'package-lock.json'); const packageLock = loadJsonObject(packageLockPath, 'package-lock.json');
const opencodePackageLock = loadJsonObject(opencodePackageLockPath, '.opencode/package-lock.json'); const opencodePackageLock = loadJsonObject(opencodePackageLockPath, '.opencode/package-lock.json');
@@ -454,6 +476,51 @@ test('README version row matches package.json', () => {
assert.strictEqual(match[1], expectedVersion); assert.strictEqual(match[1], expectedVersion);
}); });
test('user-facing docs do not use deprecated ecc@ecc install commands', () => {
const markdownFiles = [
path.join(repoRoot, 'README.md'),
path.join(repoRoot, 'README.zh-CN.md'),
path.join(repoRoot, 'skills', 'configure-ecc', 'SKILL.md'),
...collectMarkdownFiles(path.join(repoRoot, 'docs')),
];
const offenders = [];
for (const filePath of markdownFiles) {
const source = fs.readFileSync(filePath, 'utf8');
if (/\/plugin\s+(install|list)\s+ecc@ecc\b/.test(source)) {
offenders.push(path.relative(repoRoot, filePath));
}
}
assert.deepStrictEqual(
offenders,
[],
`Deprecated ecc@ecc install commands must not appear in user-facing docs: ${offenders.join(', ')}`,
);
});
test('user-facing docs do not use the legacy non-URL marketplace add form', () => {
const markdownFiles = [
path.join(repoRoot, 'README.md'),
path.join(repoRoot, 'README.zh-CN.md'),
...collectMarkdownFiles(path.join(repoRoot, 'docs')),
];
const offenders = [];
for (const filePath of markdownFiles) {
const source = fs.readFileSync(filePath, 'utf8');
if (source.includes('/plugin marketplace add affaan-m/everything-claude-code')) {
offenders.push(path.relative(repoRoot, filePath));
}
}
assert.deepStrictEqual(
offenders,
[],
`Legacy non-URL marketplace add form must not appear in user-facing docs: ${offenders.join(', ')}`,
);
});
test('docs/zh-CN/README.md version row matches package.json', () => { test('docs/zh-CN/README.md version row matches package.json', () => {
const readme = fs.readFileSync(zhCnReadmePath, 'utf8'); const readme = fs.readFileSync(zhCnReadmePath, 'utf8');
const match = readme.match(/^\| \*\*版本\*\* \| 插件 \| 插件 \| 参考配置 \| ([0-9][0-9.]*) \|$/m); const match = readme.match(/^\| \*\*版本\*\* \| 插件 \| 插件 \| 参考配置 \| ([0-9][0-9.]*) \|$/m);

View File

@@ -0,0 +1,128 @@
/**
* Behavioral tests for ecc_dashboard.py helper functions.
*/
const assert = require('assert');
const path = require('path');
const { spawnSync } = require('child_process');
const repoRoot = path.join(__dirname, '..', '..');
const runtimeHelpersPath = path.join(repoRoot, 'scripts', 'lib', 'ecc_dashboard_runtime.py');
function test(name, fn) {
try {
fn();
console.log(`${name}`);
return true;
} catch (error) {
console.log(`${name}`);
console.log(` Error: ${error.message}`);
return false;
}
}
function runPython(source) {
const candidates = process.platform === 'win32' ? ['python', 'python3'] : ['python3', 'python'];
let lastError = null;
for (const command of candidates) {
const result = spawnSync(command, ['-c', source], {
cwd: repoRoot,
encoding: 'utf8',
});
if (result.error && result.error.code === 'ENOENT') {
lastError = result.error;
continue;
}
if (result.status !== 0) {
throw new Error((result.stderr || result.stdout || '').trim() || `${command} exited ${result.status}`);
}
return result.stdout.trim();
}
throw lastError || new Error('No Python interpreter available');
}
function runTests() {
console.log('\n=== Testing ecc_dashboard.py ===\n');
let passed = 0;
let failed = 0;
if (test('build_terminal_launch keeps Linux path separate from shell command text', () => {
const output = runPython(`
import importlib.util, json
spec = importlib.util.spec_from_file_location("ecc_dashboard_runtime", r"""${runtimeHelpersPath}""")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
argv, kwargs = module.build_terminal_launch('/tmp/proj; rm -rf ~', os_name='posix', system_name='Linux')
print(json.dumps({'argv': argv, 'kwargs': kwargs}))
`);
const parsed = JSON.parse(output);
assert.deepStrictEqual(
parsed.argv,
['x-terminal-emulator', '-e', 'bash', '-lc', 'cd -- "$1"; exec bash', 'bash', '/tmp/proj; rm -rf ~']
);
assert.deepStrictEqual(parsed.kwargs, {});
})) passed++; else failed++;
if (test('build_terminal_launch uses cwd + CREATE_NEW_CONSOLE style launch on Windows', () => {
const output = runPython(`
import importlib.util, json
spec = importlib.util.spec_from_file_location("ecc_dashboard_runtime", r"""${runtimeHelpersPath}""")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
argv, kwargs = module.build_terminal_launch(r'C:\\\\Users\\\\user\\\\proj & del C:\\\\*', os_name='nt', system_name='Windows')
print(json.dumps({'argv': argv, 'kwargs': kwargs}))
`);
const parsed = JSON.parse(output);
assert.deepStrictEqual(parsed.argv.slice(0, 4), ['cmd.exe', '/k', 'cd', '/d']);
assert.strictEqual(parsed.argv[4], parsed.kwargs.cwd);
assert.ok(parsed.argv[4].includes('proj & del'), 'path should remain a literal argv entry');
assert.ok(parsed.argv[4].includes('C:'), 'windows drive prefix should be preserved');
assert.ok(Object.prototype.hasOwnProperty.call(parsed.kwargs, 'creationflags'));
})) passed++; else failed++;
if (test('maximize_window falls back to Linux zoom attribute when zoomed state is unsupported', () => {
const output = runPython(`
import importlib.util, json
spec = importlib.util.spec_from_file_location("ecc_dashboard_runtime", r"""${runtimeHelpersPath}""")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
class FakeWindow:
def __init__(self):
self.calls = []
def state(self, value):
self.calls.append(['state', value])
raise RuntimeError('bad argument "zoomed"')
def attributes(self, name, value):
self.calls.append(['attributes', name, value])
original = module.platform.system
module.platform.system = lambda: 'Linux'
try:
window = FakeWindow()
module.maximize_window(window)
finally:
module.platform.system = original
print(json.dumps(window.calls))
`);
const parsed = JSON.parse(output);
assert.deepStrictEqual(parsed, [
['state', 'zoomed'],
['attributes', '-zoomed', true],
]);
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
runTests();

View File

@@ -19,12 +19,21 @@ function cleanup(dirPath) {
} }
function run(args = [], options = {}) { function run(args = [], options = {}) {
const userProfile = options.userProfile || options.homeDir || process.env.USERPROFILE;
const env = {
...process.env,
USERPROFILE: userProfile,
};
if (Object.prototype.hasOwnProperty.call(options, 'homeDir')) {
env.HOME = options.homeDir;
} else {
env.HOME = process.env.HOME;
}
const stdout = execFileSync('node', [SCRIPT, ...args], { const stdout = execFileSync('node', [SCRIPT, ...args], {
cwd: options.cwd || path.join(__dirname, '..', '..'), cwd: options.cwd || path.join(__dirname, '..', '..'),
env: { env,
...process.env,
HOME: options.homeDir || process.env.HOME,
},
encoding: 'utf8', encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
timeout: 10000, timeout: 10000,
@@ -132,6 +141,109 @@ function runTests() {
} }
})) passed++; else failed++; })) passed++; else failed++;
if (test('detects marketplace-installed Claude plugins under home marketplaces/', () => {
const homeDir = createTempDir('harness-audit-marketplace-home-');
const projectRoot = createTempDir('harness-audit-marketplace-project-');
try {
fs.mkdirSync(path.join(homeDir, '.claude', 'plugins', 'marketplaces', 'everything-claude-code', '.claude-plugin'), { recursive: true });
fs.writeFileSync(
path.join(homeDir, '.claude', 'plugins', 'marketplaces', 'everything-claude-code', '.claude-plugin', 'plugin.json'),
JSON.stringify({ name: 'everything-claude-code' }, null, 2)
);
fs.mkdirSync(path.join(projectRoot, '.github', 'workflows'), { recursive: true });
fs.mkdirSync(path.join(projectRoot, 'tests'), { recursive: true });
fs.mkdirSync(path.join(projectRoot, '.claude'), { recursive: true });
fs.writeFileSync(path.join(projectRoot, 'AGENTS.md'), '# Project instructions\n');
fs.writeFileSync(path.join(projectRoot, '.mcp.json'), JSON.stringify({ mcpServers: {} }, null, 2));
fs.writeFileSync(path.join(projectRoot, '.gitignore'), 'node_modules\n.env\n');
fs.writeFileSync(path.join(projectRoot, '.github', 'workflows', 'ci.yml'), 'name: ci\n');
fs.writeFileSync(path.join(projectRoot, 'tests', 'app.test.js'), 'test placeholder\n');
fs.writeFileSync(path.join(projectRoot, '.claude', 'settings.json'), JSON.stringify({ hooks: ['PreToolUse'] }, null, 2));
fs.writeFileSync(
path.join(projectRoot, 'package.json'),
JSON.stringify({ name: 'consumer-project', scripts: { test: 'node tests/app.test.js' } }, null, 2)
);
const parsed = JSON.parse(run(['repo', '--format', 'json'], { cwd: projectRoot, homeDir }));
assert.ok(parsed.checks.some(check => check.id === 'consumer-plugin-install' && check.pass));
} finally {
cleanup(homeDir);
cleanup(projectRoot);
}
})) passed++; else failed++;
if (test('detects marketplace-installed Claude plugins under project marketplaces/', () => {
const homeDir = createTempDir('harness-audit-marketplace-home-');
const projectRoot = createTempDir('harness-audit-marketplace-project-');
try {
fs.mkdirSync(path.join(projectRoot, '.claude', 'plugins', 'marketplaces', 'everything-claude-code', '.claude-plugin'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, '.claude', 'plugins', 'marketplaces', 'everything-claude-code', '.claude-plugin', 'plugin.json'),
JSON.stringify({ name: 'everything-claude-code' }, null, 2)
);
fs.mkdirSync(path.join(projectRoot, '.github', 'workflows'), { recursive: true });
fs.mkdirSync(path.join(projectRoot, 'tests'), { recursive: true });
fs.mkdirSync(path.join(projectRoot, '.claude'), { recursive: true });
fs.writeFileSync(path.join(projectRoot, 'AGENTS.md'), '# Project instructions\n');
fs.writeFileSync(path.join(projectRoot, '.mcp.json'), JSON.stringify({ mcpServers: {} }, null, 2));
fs.writeFileSync(path.join(projectRoot, '.gitignore'), 'node_modules\n.env\n');
fs.writeFileSync(path.join(projectRoot, '.github', 'workflows', 'ci.yml'), 'name: ci\n');
fs.writeFileSync(path.join(projectRoot, 'tests', 'app.test.js'), 'test placeholder\n');
fs.writeFileSync(path.join(projectRoot, '.claude', 'settings.json'), JSON.stringify({ hooks: ['PreToolUse'] }, null, 2));
fs.writeFileSync(
path.join(projectRoot, 'package.json'),
JSON.stringify({ name: 'consumer-project', scripts: { test: 'node tests/app.test.js' } }, null, 2)
);
const parsed = JSON.parse(run(['repo', '--format', 'json'], { cwd: projectRoot, homeDir }));
assert.ok(parsed.checks.some(check => check.id === 'consumer-plugin-install' && check.pass));
} finally {
cleanup(homeDir);
cleanup(projectRoot);
}
})) passed++; else failed++;
if (test('detects marketplace-installed Claude plugins from USERPROFILE fallback on Windows-style setups', () => {
const homeDir = createTempDir('harness-audit-marketplace-home-');
const projectRoot = createTempDir('harness-audit-marketplace-project-');
try {
fs.mkdirSync(path.join(homeDir, '.claude', 'plugins', 'marketplaces', 'everything-claude-code', '.claude-plugin'), { recursive: true });
fs.writeFileSync(
path.join(homeDir, '.claude', 'plugins', 'marketplaces', 'everything-claude-code', '.claude-plugin', 'plugin.json'),
JSON.stringify({ name: 'everything-claude-code' }, null, 2)
);
fs.mkdirSync(path.join(projectRoot, '.github', 'workflows'), { recursive: true });
fs.mkdirSync(path.join(projectRoot, 'tests'), { recursive: true });
fs.mkdirSync(path.join(projectRoot, '.claude'), { recursive: true });
fs.writeFileSync(path.join(projectRoot, 'AGENTS.md'), '# Project instructions\n');
fs.writeFileSync(path.join(projectRoot, '.mcp.json'), JSON.stringify({ mcpServers: {} }, null, 2));
fs.writeFileSync(path.join(projectRoot, '.gitignore'), 'node_modules\n.env\n');
fs.writeFileSync(path.join(projectRoot, '.github', 'workflows', 'ci.yml'), 'name: ci\n');
fs.writeFileSync(path.join(projectRoot, 'tests', 'app.test.js'), 'test placeholder\n');
fs.writeFileSync(path.join(projectRoot, '.claude', 'settings.json'), JSON.stringify({ hooks: ['PreToolUse'] }, null, 2));
fs.writeFileSync(
path.join(projectRoot, 'package.json'),
JSON.stringify({ name: 'consumer-project', scripts: { test: 'node tests/app.test.js' } }, null, 2)
);
const parsed = JSON.parse(run(['repo', '--format', 'json'], {
cwd: projectRoot,
homeDir: '',
userProfile: homeDir,
}));
assert.ok(parsed.checks.some(check => check.id === 'consumer-plugin-install' && check.pass));
} finally {
cleanup(homeDir);
cleanup(projectRoot);
}
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0); process.exit(failed > 0 ? 1 : 0);
} }

View File

@@ -350,7 +350,7 @@ function runTests() {
} }
})) passed++; else failed++; })) passed++; else failed++;
if (test('resolves CLAUDE_PLUGIN_ROOT placeholders in installed claude hooks', () => { if (test('installs claude hooks with the safe plugin bootstrap contract', () => {
const homeDir = createTempDir('install-apply-home-'); const homeDir = createTempDir('install-apply-home-');
const projectDir = createTempDir('install-apply-project-'); const projectDir = createTempDir('install-apply-project-');
@@ -361,18 +361,28 @@ function runTests() {
const claudeRoot = path.join(homeDir, '.claude'); const claudeRoot = path.join(homeDir, '.claude');
const installedHooks = readJson(path.join(claudeRoot, 'hooks', 'hooks.json')); const installedHooks = readJson(path.join(claudeRoot, 'hooks', 'hooks.json'));
const normSep = (s) => s.replace(/\\/g, '/'); const installedBashDispatcherEntry = installedHooks.hooks.PreToolUse.find(entry => entry.id === 'pre:bash:dispatcher');
const expectedFragment = normSep(path.join(claudeRoot, 'scripts', 'hooks', 'auto-tmux-dev.js')); assert.ok(installedBashDispatcherEntry, 'hooks/hooks.json should include the consolidated Bash dispatcher hook');
assert.ok(Array.isArray(installedBashDispatcherEntry.hooks[0].command), 'hooks/hooks.json should install argv-form commands for cross-platform safety');
const installedAutoTmuxEntry = installedHooks.hooks.PreToolUse.find(entry => entry.id === 'pre:bash:auto-tmux-dev');
assert.ok(installedAutoTmuxEntry, 'hooks/hooks.json should include the auto tmux hook');
assert.ok( assert.ok(
normSep(installedAutoTmuxEntry.hooks[0].command).includes(expectedFragment), installedBashDispatcherEntry.hooks[0].command[0] === 'node' && installedBashDispatcherEntry.hooks[0].command[1] === '-e',
'hooks/hooks.json should use the installed Claude root for hook commands' 'hooks/hooks.json should use the inline node bootstrap contract'
); );
assert.ok( assert.ok(
!installedAutoTmuxEntry.hooks[0].command.includes('${CLAUDE_PLUGIN_ROOT}'), installedBashDispatcherEntry.hooks[0].command.some(part => String(part).includes('plugin-hook-bootstrap.js')),
'hooks/hooks.json should not retain CLAUDE_PLUGIN_ROOT placeholders after install' 'hooks/hooks.json should route plugin-managed hooks through the shared bootstrap'
);
assert.ok(
installedBashDispatcherEntry.hooks[0].command.some(part => String(part).includes('CLAUDE_PLUGIN_ROOT')),
'hooks/hooks.json should still consult CLAUDE_PLUGIN_ROOT for runtime resolution'
);
assert.ok(
installedBashDispatcherEntry.hooks[0].command.some(part => String(part).includes('pre-bash-dispatcher.js')),
'hooks/hooks.json should point the Bash preflight contract at the consolidated dispatcher'
);
assert.ok(
!installedBashDispatcherEntry.hooks[0].command.some(part => String(part).includes('${CLAUDE_PLUGIN_ROOT}')),
'hooks/hooks.json should not retain raw CLAUDE_PLUGIN_ROOT shell placeholders after install'
); );
} finally { } finally {
cleanup(homeDir); cleanup(homeDir);