mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-18 16:13:29 +08:00
Compare commits
22 Commits
dependabot
...
fix/bash-h
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a9918db00 | ||
|
|
a8bb5979a5 | ||
|
|
1c45152c6d | ||
|
|
5bfb3cc563 | ||
|
|
5427c27930 | ||
|
|
8da668f1ac | ||
|
|
1b7c5789fc | ||
|
|
cdeb837838 | ||
|
|
cca163c776 | ||
|
|
c54b44edf3 | ||
|
|
2691cfc0f1 | ||
|
|
b2c4b7f51c | ||
|
|
c924290b5b | ||
|
|
e46deb93c8 | ||
|
|
8776c4f8f3 | ||
|
|
e5225db006 | ||
|
|
48a30b53c8 | ||
|
|
3be24a5704 | ||
|
|
76b6e22b4d | ||
|
|
ecc5e0e2d6 | ||
|
|
aa96279ecc | ||
|
|
68ee51f1e3 |
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
@@ -145,8 +145,8 @@ Troubleshoot failures: check test isolation → verify mocks → fix implementat
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
agents/ — 47 specialized subagents
|
||||
skills/ — 181 workflow skills and domain knowledge
|
||||
agents/ — 48 specialized subagents
|
||||
skills/ — 183 workflow skills and domain knowledge
|
||||
commands/ — 79 slash commands
|
||||
hooks/ — Trigger-based automations
|
||||
rules/ — Always-follow guidelines (common + per-language)
|
||||
|
||||
34
README.md
34
README.md
@@ -174,9 +174,11 @@ Get up and running in under 2 minutes:
|
||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
||||
|
||||
# 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)
|
||||
|
||||
> 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"
|
||||
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
|
||||
# Install the plugin
|
||||
/plugin install ecc@ecc
|
||||
/plugin install everything-claude-code
|
||||
```
|
||||
|
||||
Or add directly to your `~/.claude/settings.json`:
|
||||
@@ -664,7 +666,7 @@ Or add directly to your `~/.claude/settings.json`:
|
||||
}
|
||||
},
|
||||
"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
|
||||
|
||||
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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
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`.
|
||||
|
||||
@@ -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>
|
||||
|
||||
```bash
|
||||
/plugin list ecc@ecc
|
||||
/plugin list everything-claude-code@everything-claude-code
|
||||
```
|
||||
|
||||
This shows all available agents, commands, and skills from the plugin.
|
||||
@@ -1013,6 +1015,14 @@ Please contribute! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
- Testing strategies (different frameworks, visual regression)
|
||||
- Domain-specific knowledge (ML, data engineering, mobile)
|
||||
|
||||
### Community Ecosystem Notes
|
||||
|
||||
These are not bundled with ECC and are not audited by this repo, but they are worth knowing about if you are exploring the broader Claude Code skills ecosystem:
|
||||
|
||||
- [claude-seo](https://github.com/AgriciDaniel/claude-seo) — SEO-focused skill and agent collection
|
||||
- [claude-ads](https://github.com/AgriciDaniel/claude-ads) — Ad-audit and paid-growth workflow collection
|
||||
- [claude-cybersecurity](https://github.com/AgriciDaniel/claude-cybersecurity) — Security-oriented skill and agent collection
|
||||
|
||||
---
|
||||
|
||||
## Cursor IDE Support
|
||||
@@ -1197,9 +1207,9 @@ The configuration is automatically detected from `.opencode/opencode.json`.
|
||||
|
||||
| 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** |
|
||||
| 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!** |
|
||||
| Rules | PASS: 29 rules | PASS: 13 instructions | **Claude Code leads** |
|
||||
| MCP Servers | PASS: 14 servers | PASS: Full | **Full parity** |
|
||||
@@ -1306,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 |
|
||||
|---------|------------|------------|-----------|----------|
|
||||
| **Agents** | 47 | Shared (AGENTS.md) | Shared (AGENTS.md) | 12 |
|
||||
| **Agents** | 48 | Shared (AGENTS.md) | Shared (AGENTS.md) | 12 |
|
||||
| **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 Scripts** | 20+ scripts | 16 scripts (DRY adapter) | N/A | Plugin hooks |
|
||||
| **Rules** | 34 (common + lang) | 34 (YAML frontmatter) | Instruction-based | 13 instructions |
|
||||
|
||||
@@ -99,12 +99,14 @@
|
||||
|
||||
```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`,需要手动安装:
|
||||
@@ -159,10 +161,10 @@ npx ecc-install typescript
|
||||
# /plan "添加用户认证"
|
||||
|
||||
# 查看可用命令
|
||||
/plugin list ecc@ecc
|
||||
/plugin list everything-claude-code@everything-claude-code
|
||||
```
|
||||
|
||||
**完成!** 你现在可以使用 47 个代理、181 个技能和 79 个命令。
|
||||
**完成!** 你现在可以使用 48 个代理、183 个技能和 79 个命令。
|
||||
|
||||
### multi-* 命令需要额外配置
|
||||
|
||||
@@ -543,10 +545,10 @@ Claude Code v2.1+ 会**按照约定自动加载**已安装插件中的 `hooks/ho
|
||||
|
||||
```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`:
|
||||
@@ -562,7 +564,7 @@ Claude Code v2.1+ 会**按照约定自动加载**已安装插件中的 `hooks/ho
|
||||
}
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"ecc@ecc": true
|
||||
"everything-claude-code@everything-claude-code": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
---
|
||||
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.
|
||||
model: sonnet
|
||||
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.
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
||||
|
||||
# プラグインをインストール
|
||||
/plugin install ecc@ecc
|
||||
/plugin install everything-claude-code
|
||||
```
|
||||
|
||||
### ステップ2:ルールをインストール(必須)
|
||||
@@ -140,7 +140,7 @@ cp -r everything-claude-code/rules/golang/* ~/.claude/rules/
|
||||
# /plan "ユーザー認証を追加"
|
||||
|
||||
# 利用可能なコマンドを確認
|
||||
/plugin list ecc@ecc
|
||||
/plugin list everything-claude-code@everything-claude-code
|
||||
```
|
||||
|
||||
**完了です!** これで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 install ecc@ecc
|
||||
/plugin install everything-claude-code
|
||||
```
|
||||
|
||||
または、`~/.claude/settings.json` に直接追加:
|
||||
@@ -443,7 +443,7 @@ Duplicate hook file detected: ./hooks/hooks.json is already resolved to a loaded
|
||||
}
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"ecc@ecc": true
|
||||
"everything-claude-code@everything-claude-code": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -17,7 +17,7 @@ Everything Claude Code プロジェクトのインタラクティブなステッ
|
||||
## 前提条件
|
||||
|
||||
このスキルは起動前に Claude Code からアクセス可能である必要があります。ブートストラップには2つの方法があります:
|
||||
1. **プラグイン経由**: `/plugin install ecc@ecc` — プラグインがこのスキルを自動的にロードします
|
||||
1. **プラグイン経由**: `/plugin install everything-claude-code` — プラグインがこのスキルを自動的にロードします
|
||||
2. **手動**: このスキルのみを `~/.claude/skills/configure-ecc/SKILL.md` にコピーし、"configure ecc" と言って起動します
|
||||
|
||||
---
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
||||
|
||||
# 플러그인 설치
|
||||
/plugin install ecc@ecc
|
||||
/plugin install everything-claude-code
|
||||
```
|
||||
|
||||
### 2단계: 룰 설치 (필수)
|
||||
@@ -147,7 +147,7 @@ cd everything-claude-code
|
||||
# /plan "사용자 인증 추가"
|
||||
|
||||
# 사용 가능한 커맨드 확인
|
||||
/plugin list ecc@ecc
|
||||
/plugin list everything-claude-code@everything-claude-code
|
||||
```
|
||||
|
||||
**끝!** 이제 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 install ecc@ecc
|
||||
/plugin install everything-claude-code
|
||||
```
|
||||
|
||||
또는 `~/.claude/settings.json`에 직접 추가:
|
||||
@@ -375,7 +375,7 @@ Claude Code v2.1+는 설치된 플러그인의 `hooks/hooks.json`을 **자동으
|
||||
}
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"ecc@ecc": true
|
||||
"everything-claude-code@everything-claude-code": true
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -535,7 +535,7 @@ rules/
|
||||
<summary><b>설치된 에이전트/커맨드 확인은 어떻게 하나요?</b></summary>
|
||||
|
||||
```bash
|
||||
/plugin list ecc@ecc
|
||||
/plugin list everything-claude-code@everything-claude-code
|
||||
```
|
||||
|
||||
플러그인에서 사용할 수 있는 모든 에이전트, 커맨드, 스킬을 보여줍니다.
|
||||
|
||||
@@ -124,7 +124,7 @@ Comece em menos de 2 minutos:
|
||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
||||
|
||||
# Instalar plugin
|
||||
/plugin install ecc@ecc
|
||||
/plugin install everything-claude-code
|
||||
```
|
||||
|
||||
### Passo 2: Instalar as Regras (Obrigatório)
|
||||
@@ -167,7 +167,7 @@ npx ecc-install typescript
|
||||
# /plan "Adicionar autenticação de usuário"
|
||||
|
||||
# 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.
|
||||
@@ -313,7 +313,7 @@ claude --version
|
||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
||||
|
||||
# Instalar o plugin
|
||||
/plugin install ecc@ecc
|
||||
/plugin install everything-claude-code
|
||||
```
|
||||
|
||||
Ou adicione diretamente ao seu `~/.claude/settings.json`:
|
||||
@@ -329,7 +329,7 @@ Ou adicione diretamente ao seu `~/.claude/settings.json`:
|
||||
}
|
||||
},
|
||||
"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>
|
||||
|
||||
```bash
|
||||
/plugin list ecc@ecc
|
||||
/plugin list everything-claude-code@everything-claude-code
|
||||
```
|
||||
</details>
|
||||
|
||||
|
||||
@@ -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'i kur
|
||||
/plugin install ecc@ecc
|
||||
/plugin install everything-claude-code
|
||||
```
|
||||
|
||||
### 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"
|
||||
|
||||
# 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.
|
||||
@@ -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>
|
||||
|
||||
```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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Everything Claude Code (ECC) — 智能体指令
|
||||
|
||||
这是一个**生产就绪的 AI 编码插件**,提供 47 个专业代理、181 项技能、79 条命令以及自动化钩子工作流,用于软件开发。
|
||||
这是一个**生产就绪的 AI 编码插件**,提供 48 个专业代理、183 项技能、79 条命令以及自动化钩子工作流,用于软件开发。
|
||||
|
||||
**版本:** 1.10.0
|
||||
|
||||
@@ -146,8 +146,8 @@
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
agents/ — 47 个专业子代理
|
||||
skills/ — 181 个工作流技能和领域知识
|
||||
agents/ — 48 个专业子代理
|
||||
skills/ — 183 个工作流技能和领域知识
|
||||
commands/ — 79 个斜杠命令
|
||||
hooks/ — 基于触发的自动化
|
||||
rules/ — 始终遵循的指导方针(通用 + 每种语言)
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
||||
|
||||
# Install plugin
|
||||
/plugin install ecc@ecc
|
||||
/plugin install everything-claude-code
|
||||
```
|
||||
|
||||
### 步骤 2:安装规则(必需)
|
||||
@@ -206,10 +206,10 @@ npx ecc-install typescript
|
||||
# /plan "Add user authentication"
|
||||
|
||||
# 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
|
||||
|
||||
# Install the plugin
|
||||
/plugin install ecc@ecc
|
||||
/plugin install everything-claude-code
|
||||
```
|
||||
|
||||
或者直接添加到您的 `~/.claude/settings.json`:
|
||||
@@ -601,7 +601,7 @@ Claude Code v2.1+ **会自动加载** 任何已安装插件中的 `hooks/hooks.j
|
||||
}
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"ecc@ecc": true
|
||||
"everything-claude-code@everything-claude-code": true
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -793,7 +793,7 @@ rules/
|
||||
<summary><b>如何检查已安装的代理/命令?</b></summary>
|
||||
|
||||
```bash
|
||||
/plugin list ecc@ecc
|
||||
/plugin list everything-claude-code@everything-claude-code
|
||||
```
|
||||
|
||||
这会显示插件中所有可用的代理、命令和技能。
|
||||
@@ -1094,9 +1094,9 @@ opencode
|
||||
|
||||
| 功能特性 | Claude Code | OpenCode | 状态 |
|
||||
|---------|-------------|----------|--------|
|
||||
| 智能体 | PASS: 47 个 | PASS: 12 个 | **Claude Code 领先** |
|
||||
| 智能体 | PASS: 48 个 | PASS: 12 个 | **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: 29 条 | PASS: 13 条指令 | **Claude Code 领先** |
|
||||
| MCP 服务器 | PASS: 14 个 | PASS: 完整 | **完全对等** |
|
||||
@@ -1206,9 +1206,9 @@ ECC 是**第一个最大化利用每个主要 AI 编码工具的插件**。以
|
||||
|
||||
| 功能特性 | Claude Code | Cursor IDE | Codex CLI | OpenCode |
|
||||
|---------|------------|------------|-----------|----------|
|
||||
| **智能体** | 47 | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 |
|
||||
| **智能体** | 48 | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 |
|
||||
| **命令** | 79 | 共享 | 基于指令 | 31 |
|
||||
| **技能** | 181 | 共享 | 10 (原生格式) | 37 |
|
||||
| **技能** | 183 | 共享 | 10 (原生格式) | 37 |
|
||||
| **钩子事件** | 8 种类型 | 15 种类型 | 暂无 | 11 种类型 |
|
||||
| **钩子脚本** | 20+ 个脚本 | 16 个脚本 (DRY 适配器) | N/A | 插件钩子 |
|
||||
| **规则** | 34 (通用 + 语言) | 34 (YAML 前页) | 基于指令 | 13 条指令 |
|
||||
|
||||
@@ -19,7 +19,7 @@ origin: ECC
|
||||
|
||||
此技能必须在激活前对 Claude Code 可访问。有两种引导方式:
|
||||
|
||||
1. **通过插件**: `/plugin install ecc@ecc` — 插件会自动加载此技能
|
||||
1. **通过插件**: `/plugin install everything-claude-code` — 插件会自动加载此技能
|
||||
2. **手动**: 仅将此技能复制到 `~/.claude/skills/configure-ecc/SKILL.md`,然后通过说 "configure ecc" 激活
|
||||
|
||||
***
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
/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 "新增使用者認證"
|
||||
|
||||
# 查看可用指令
|
||||
/plugin list ecc@ecc
|
||||
/plugin list everything-claude-code@everything-claude-code
|
||||
```
|
||||
|
||||
**完成!** 您現在使用 15+ 代理程式、30+ 技能和 20+ 指令。
|
||||
@@ -270,7 +270,7 @@ 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`:
|
||||
@@ -286,7 +286,7 @@ everything-claude-code/
|
||||
}
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"ecc@ecc": true
|
||||
"everything-claude-code@everything-claude-code": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -8,8 +8,11 @@ import tkinter as tk
|
||||
from tkinter import ttk, scrolledtext, messagebox
|
||||
import os
|
||||
import json
|
||||
import subprocess
|
||||
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
|
||||
# ============================================================================
|
||||
@@ -18,6 +21,7 @@ def get_project_path() -> str:
|
||||
"""Get the ECC project path - assumes this script is run from the project dir"""
|
||||
return os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def load_agents(project_path: str) -> List[Dict]:
|
||||
"""Load agents from 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.title("ECC Dashboard - Everything Claude Code")
|
||||
|
||||
self.state('zoomed')
|
||||
maximize_window(self)
|
||||
|
||||
try:
|
||||
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):
|
||||
"""Open terminal at project path"""
|
||||
import subprocess
|
||||
path = self.path_entry.get()
|
||||
if os.name == 'nt': # Windows
|
||||
subprocess.Popen(['cmd', '/c', 'start', 'cmd', '/k', f'cd /d "{path}"'])
|
||||
elif os.uname().sysname == 'Darwin': # macOS
|
||||
subprocess.Popen(['open', '-a', 'Terminal', path])
|
||||
else: # Linux
|
||||
subprocess.Popen(['x-terminal-emulator', '-e', f'cd {path}'])
|
||||
argv, kwargs = build_terminal_launch(path)
|
||||
subprocess.Popen(argv, **kwargs)
|
||||
|
||||
def open_readme(self):
|
||||
"""Open README in default browser/reader"""
|
||||
@@ -911,4 +910,4 @@ def main():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
|
||||
@@ -18,7 +18,7 @@ User request → Claude picks a tool → PreToolUse hook runs → Tool executes
|
||||
|
||||
## 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:
|
||||
|
||||
|
||||
297
hooks/hooks.json
297
hooks/hooks.json
@@ -7,62 +7,33 @@
|
||||
"hooks": [
|
||||
{
|
||||
"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",
|
||||
"id": "pre:bash:block-no-verify"
|
||||
},
|
||||
{
|
||||
"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"
|
||||
"description": "Consolidated Bash preflight dispatcher for quality, tmux, push, and GateGuard checks",
|
||||
"id": "pre:bash:dispatcher"
|
||||
},
|
||||
{
|
||||
"matcher": "Write",
|
||||
"hooks": [
|
||||
{
|
||||
"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)",
|
||||
@@ -73,7 +44,16 @@
|
||||
"hooks": [
|
||||
{
|
||||
"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",
|
||||
@@ -84,7 +64,16 @@
|
||||
"hooks": [
|
||||
{
|
||||
"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,
|
||||
"timeout": 10
|
||||
}
|
||||
@@ -97,7 +86,16 @@
|
||||
"hooks": [
|
||||
{
|
||||
"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
|
||||
}
|
||||
],
|
||||
@@ -109,7 +107,16 @@
|
||||
"hooks": [
|
||||
{
|
||||
"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
|
||||
}
|
||||
],
|
||||
@@ -121,7 +128,16 @@
|
||||
"hooks": [
|
||||
{
|
||||
"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",
|
||||
@@ -132,24 +148,21 @@
|
||||
"hooks": [
|
||||
{
|
||||
"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
|
||||
}
|
||||
],
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
@@ -158,7 +171,16 @@
|
||||
"hooks": [
|
||||
{
|
||||
"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",
|
||||
@@ -171,7 +193,13 @@
|
||||
"hooks": [
|
||||
{
|
||||
"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",
|
||||
@@ -184,53 +212,35 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/post-bash-command-log.js\" audit"
|
||||
}
|
||||
],
|
||||
"description": "Audit log all bash commands to ~/.claude/bash-commands.log",
|
||||
"id": "post:bash:command-log-audit"
|
||||
},
|
||||
{
|
||||
"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\"",
|
||||
"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/post-bash-dispatcher.js"
|
||||
],
|
||||
"async": true,
|
||||
"timeout": 30
|
||||
}
|
||||
],
|
||||
"description": "Example: async hook for build analysis (runs in background without blocking)",
|
||||
"id": "post:bash:build-complete"
|
||||
"description": "Consolidated Bash postflight dispatcher for logging, PR, and build notifications",
|
||||
"id": "post:bash:dispatcher"
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"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,
|
||||
"timeout": 30
|
||||
}
|
||||
@@ -243,7 +253,16 @@
|
||||
"hooks": [
|
||||
{
|
||||
"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
|
||||
}
|
||||
],
|
||||
@@ -255,7 +274,16 @@
|
||||
"hooks": [
|
||||
{
|
||||
"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",
|
||||
@@ -266,7 +294,16 @@
|
||||
"hooks": [
|
||||
{
|
||||
"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",
|
||||
@@ -277,7 +314,16 @@
|
||||
"hooks": [
|
||||
{
|
||||
"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
|
||||
}
|
||||
],
|
||||
@@ -289,7 +335,16 @@
|
||||
"hooks": [
|
||||
{
|
||||
"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
|
||||
}
|
||||
],
|
||||
@@ -301,7 +356,16 @@
|
||||
"hooks": [
|
||||
{
|
||||
"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,
|
||||
"timeout": 10
|
||||
}
|
||||
@@ -316,7 +380,16 @@
|
||||
"hooks": [
|
||||
{
|
||||
"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",
|
||||
|
||||
@@ -73,7 +73,7 @@ function validateHookEntry(hook, label) {
|
||||
console.error(`ERROR: ${label} missing or invalid 'command' field`);
|
||||
hasErrors = true;
|
||||
} 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) {
|
||||
try {
|
||||
new vm.Script(nodeEMatch[1].replace(/\\\\/g, '\\').replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\t/g, '\t'));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const CATEGORIES = [
|
||||
@@ -187,7 +188,7 @@ function detectTargetMode(rootDir) {
|
||||
}
|
||||
|
||||
function findPluginInstall(rootDir) {
|
||||
const homeDir = process.env.HOME || '';
|
||||
const homeDir = process.env.HOME || process.env.USERPROFILE || os.homedir() || '';
|
||||
const pluginDirs = [
|
||||
'ecc',
|
||||
'ecc@ecc',
|
||||
@@ -196,7 +197,9 @@ function findPluginInstall(rootDir) {
|
||||
];
|
||||
const candidateRoots = [
|
||||
path.join(rootDir, '.claude', 'plugins'),
|
||||
path.join(rootDir, '.claude', 'plugins', 'marketplaces'),
|
||||
homeDir && path.join(homeDir, '.claude', 'plugins'),
|
||||
homeDir && path.join(homeDir, '.claude', 'plugins', 'marketplaces'),
|
||||
].filter(Boolean);
|
||||
const candidates = candidateRoots.flatMap((pluginsDir) =>
|
||||
pluginDirs.flatMap((pluginDir) => [
|
||||
|
||||
@@ -30,19 +30,10 @@ const { spawnSync } = require('child_process');
|
||||
|
||||
const MAX_STDIN = 1024 * 1024; // 1MB limit
|
||||
let data = '';
|
||||
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', () => {
|
||||
let input;
|
||||
function run(rawInput) {
|
||||
try {
|
||||
input = JSON.parse(data);
|
||||
const input = typeof rawInput === 'string' ? JSON.parse(rawInput) : rawInput;
|
||||
const cmd = input.tool_input?.command || '';
|
||||
|
||||
// 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)
|
||||
// Escape double quotes in cmd for cmd /k syntax
|
||||
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 {
|
||||
// Unix (macOS/Linux): Check tmux is available before transforming
|
||||
const tmuxCheck = spawnSync('which', ['tmux'], { encoding: 'utf8' });
|
||||
@@ -73,16 +70,38 @@ process.stdin.on('end', () => {
|
||||
// 2. Create new detached session with the dev command
|
||||
// 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"`;
|
||||
|
||||
input.tool_input.command = transformedCmd;
|
||||
return JSON.stringify({
|
||||
...input,
|
||||
tool_input: {
|
||||
...input.tool_input,
|
||||
command: transformedCmd,
|
||||
},
|
||||
});
|
||||
}
|
||||
// else: tmux not found, pass through original command unchanged
|
||||
}
|
||||
}
|
||||
process.stdout.write(JSON.stringify(input));
|
||||
|
||||
return JSON.stringify(input);
|
||||
} catch {
|
||||
// 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 };
|
||||
|
||||
177
scripts/hooks/bash-hook-dispatcher.js
Normal file
177
scripts/hooks/bash-hook-dispatcher.js
Normal 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,
|
||||
};
|
||||
@@ -27,13 +27,12 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// 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 SESSION_ID = process.env.CLAUDE_SESSION_ID || process.env.ECC_SESSION_ID || `pid-${process.ppid || process.pid}`;
|
||||
const STATE_FILE = path.join(STATE_DIR, `state-${SESSION_ID.replace(/[^a-zA-Z0-9_-]/g, '_')}.json`);
|
||||
let activeStateFile = null;
|
||||
|
||||
// State expires after 30 minutes of inactivity
|
||||
const SESSION_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
const READ_HEARTBEAT_MS = 60 * 1000;
|
||||
|
||||
// Maximum checked entries to prevent unbounded growth
|
||||
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) ---
|
||||
|
||||
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() {
|
||||
const stateFile = getStateFile();
|
||||
try {
|
||||
if (fs.existsSync(STATE_FILE)) {
|
||||
const state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
||||
if (fs.existsSync(stateFile)) {
|
||||
const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
||||
const lastActive = state.last_active || 0;
|
||||
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 state;
|
||||
@@ -75,15 +126,30 @@ function pruneCheckedEntries(checked) {
|
||||
}
|
||||
|
||||
function saveState(state) {
|
||||
const stateFile = getStateFile();
|
||||
let tmpFile = null;
|
||||
try {
|
||||
state.last_active = Date.now();
|
||||
state.checked = pruneCheckedEntries(state.checked);
|
||||
fs.mkdirSync(STATE_DIR, { recursive: true });
|
||||
// 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.renameSync(tmpFile, STATE_FILE);
|
||||
} catch (_) { /* ignore */ }
|
||||
try {
|
||||
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) {
|
||||
@@ -97,7 +163,9 @@ function markChecked(key) {
|
||||
function isChecked(key) {
|
||||
const state = loadState();
|
||||
const found = state.checked.includes(key);
|
||||
saveState(state);
|
||||
if (found && Date.now() - (state.last_active || 0) > READ_HEARTBEAT_MS) {
|
||||
saveState(state);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
@@ -109,9 +177,13 @@ function isChecked(key) {
|
||||
for (const f of files) {
|
||||
if (!f.startsWith('state-') || !f.endsWith('.json')) continue;
|
||||
const fp = path.join(STATE_DIR, f);
|
||||
const stat = fs.statSync(fp);
|
||||
if (now - stat.mtimeMs > SESSION_TIMEOUT_MS * 2) {
|
||||
fs.unlinkSync(fp);
|
||||
try {
|
||||
const stat = fs.statSync(fp);
|
||||
if (now - stat.mtimeMs > SESSION_TIMEOUT_MS * 2) {
|
||||
fs.unlinkSync(fp);
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore files that disappear between readdir/stat/unlink.
|
||||
}
|
||||
}
|
||||
} catch (_) { /* ignore */ }
|
||||
@@ -121,7 +193,64 @@ function isChecked(key) {
|
||||
|
||||
function sanitizePath(filePath) {
|
||||
// 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 ---
|
||||
@@ -205,6 +334,8 @@ function run(rawInput) {
|
||||
} catch (_) {
|
||||
return rawInput; // allow on parse error
|
||||
}
|
||||
activeStateFile = null;
|
||||
getStateFile(data);
|
||||
|
||||
const rawToolName = data.tool_name || '';
|
||||
const toolInput = data.tool_input || {};
|
||||
@@ -214,7 +345,7 @@ function run(rawInput) {
|
||||
|
||||
if (toolName === 'Edit' || toolName === 'Write') {
|
||||
const filePath = toolInput.file_path || '';
|
||||
if (!filePath) {
|
||||
if (!filePath || isClaudeSettingsPath(filePath)) {
|
||||
return rawInput; // allow
|
||||
}
|
||||
|
||||
@@ -230,7 +361,7 @@ function run(rawInput) {
|
||||
const edits = toolInput.edits || [];
|
||||
for (const edit of edits) {
|
||||
const filePath = edit.file_path || '';
|
||||
if (filePath && !isChecked(filePath)) {
|
||||
if (filePath && !isClaudeSettingsPath(filePath) && !isChecked(filePath)) {
|
||||
markChecked(filePath);
|
||||
return denyResult(editGateMsg(filePath));
|
||||
}
|
||||
@@ -240,6 +371,9 @@ function run(rawInput) {
|
||||
|
||||
if (toolName === 'Bash') {
|
||||
const command = toolInput.command || '';
|
||||
if (isReadOnlyGitIntrospection(command)) {
|
||||
return rawInput;
|
||||
}
|
||||
|
||||
if (DESTRUCTIVE_BASH.test(command)) {
|
||||
// Gate destructive commands on first attempt; allow retry after facts presented
|
||||
|
||||
153
scripts/hooks/plugin-hook-bootstrap.js
Normal file
153
scripts/hooks/plugin-hook-bootstrap.js
Normal 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();
|
||||
@@ -4,24 +4,46 @@
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
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', () => {
|
||||
function run(rawInput) {
|
||||
try {
|
||||
const input = JSON.parse(raw);
|
||||
const input = typeof rawInput === 'string' ? JSON.parse(rawInput) : rawInput;
|
||||
const cmd = String(input.tool_input?.command || '');
|
||||
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 {
|
||||
// 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 };
|
||||
|
||||
@@ -38,8 +38,24 @@ function appendLine(filePath, line) {
|
||||
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() {
|
||||
const config = MODE_CONFIG[process.argv[2]];
|
||||
const mode = process.argv[2];
|
||||
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
@@ -50,17 +66,7 @@ function main() {
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
try {
|
||||
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);
|
||||
process.stdout.write(run(raw, mode));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -69,5 +75,6 @@ if (require.main === module) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
run,
|
||||
sanitizeCommand,
|
||||
};
|
||||
|
||||
24
scripts/hooks/post-bash-dispatcher.js
Normal file
24
scripts/hooks/post-bash-dispatcher.js
Normal 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;
|
||||
});
|
||||
@@ -4,17 +4,9 @@
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
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', () => {
|
||||
function run(rawInput) {
|
||||
try {
|
||||
const input = JSON.parse(raw);
|
||||
const input = typeof rawInput === 'string' ? JSON.parse(rawInput) : rawInput;
|
||||
const cmd = String(input.tool_input?.command || '');
|
||||
|
||||
if (/\bgh\s+pr\s+create\b/.test(cmd)) {
|
||||
@@ -24,13 +16,45 @@ process.stdin.on('end', () => {
|
||||
const prUrl = match[0];
|
||||
const repo = prUrl.replace(/https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/\d+/, '$1');
|
||||
const prNum = prUrl.replace(/.+\/pull\/(\d+)/, '$1');
|
||||
console.error(`[Hook] PR created: ${prUrl}`);
|
||||
console.error(`[Hook] To review: gh pr review ${prNum} --repo ${repo}`);
|
||||
return {
|
||||
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 {
|
||||
// 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 };
|
||||
|
||||
@@ -380,7 +380,11 @@ function evaluate(rawInput) {
|
||||
}
|
||||
|
||||
function run(rawInput) {
|
||||
return evaluate(rawInput).output;
|
||||
const result = evaluate(rawInput);
|
||||
return {
|
||||
stdout: result.output,
|
||||
exitCode: result.exitCode,
|
||||
};
|
||||
}
|
||||
|
||||
// ── stdin entry point ────────────────────────────────────────────
|
||||
|
||||
24
scripts/hooks/pre-bash-dispatcher.js
Normal file
24
scripts/hooks/pre-bash-dispatcher.js
Normal 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;
|
||||
});
|
||||
@@ -4,25 +4,49 @@
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
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', () => {
|
||||
function run(rawInput) {
|
||||
try {
|
||||
const input = JSON.parse(raw);
|
||||
const input = typeof rawInput === 'string' ? JSON.parse(rawInput) : rawInput;
|
||||
const cmd = String(input.tool_input?.command || '');
|
||||
if (/\bgit\s+push\b/.test(cmd)) {
|
||||
console.error('[Hook] Review changes before push...');
|
||||
console.error('[Hook] Continuing with push (remove this hook to add interactive review)');
|
||||
return {
|
||||
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 {
|
||||
// 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 };
|
||||
|
||||
@@ -4,17 +4,9 @@
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
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', () => {
|
||||
function run(rawInput) {
|
||||
try {
|
||||
const input = JSON.parse(raw);
|
||||
const input = typeof rawInput === 'string' ? JSON.parse(rawInput) : rawInput;
|
||||
const cmd = String(input.tool_input?.command || '');
|
||||
|
||||
if (
|
||||
@@ -22,12 +14,44 @@ process.stdin.on('end', () => {
|
||||
!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)
|
||||
) {
|
||||
console.error('[Hook] Consider running in tmux for session persistence');
|
||||
console.error('[Hook] tmux new -s dev | tmux attach -t dev');
|
||||
return {
|
||||
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 {
|
||||
// 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 };
|
||||
|
||||
61
scripts/lib/ecc_dashboard_runtime.py
Normal file
61
scripts/lib/ecc_dashboard_runtime.py
Normal 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],
|
||||
{},
|
||||
)
|
||||
@@ -53,11 +53,11 @@ module.exports = createInstallTargetAdapter({
|
||||
}));
|
||||
}).sort((left, right) => {
|
||||
const getPriority = value => {
|
||||
if (value === 'rules') {
|
||||
if (value === '.cursor') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (value === '.cursor') {
|
||||
if (value === 'rules') {
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ An interactive, step-by-step installation wizard for the Everything Claude Code
|
||||
## Prerequisites
|
||||
|
||||
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"
|
||||
|
||||
---
|
||||
|
||||
114
tests/hooks/bash-hook-dispatcher.test.js
Normal file
114
tests/hooks/bash-hook-dispatcher.test.js
Normal 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();
|
||||
@@ -10,10 +10,12 @@ const { spawnSync } = require('child_process');
|
||||
const runner = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'run-with-flags.js');
|
||||
const externalStateDir = process.env.GATEGUARD_STATE_DIR;
|
||||
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
|
||||
const TEST_SESSION_ID = 'gateguard-test-session';
|
||||
const stateFile = path.join(stateDir, `state-${TEST_SESSION_ID}.json`);
|
||||
const READ_HEARTBEAT_MS = 60 * 1000;
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
@@ -29,11 +31,12 @@ function test(name, fn) {
|
||||
|
||||
function clearState() {
|
||||
try {
|
||||
if (fs.existsSync(stateFile)) {
|
||||
fs.unlinkSync(stateFile);
|
||||
if (fs.existsSync(stateDir)) {
|
||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
} 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++;
|
||||
|
||||
// --- Test 12: reads refresh active session state ---
|
||||
// --- Test 12: hot-path reads do not rewrite state within heartbeat ---
|
||||
clearState();
|
||||
if (test('touches last_active on read so active sessions do not age out', () => {
|
||||
const staleButActive = Date.now() - (29 * 60 * 1000);
|
||||
if (test('does not rewrite state on hot-path reads within heartbeat window', () => {
|
||||
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({
|
||||
checked: ['/src/keep-alive.js'],
|
||||
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({
|
||||
tool_name: 'Edit',
|
||||
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'));
|
||||
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++;
|
||||
|
||||
// --- Test 13: pruning preserves routine bash gate marker ---
|
||||
// --- Test 14: pruning preserves routine bash gate marker ---
|
||||
clearState();
|
||||
if (test('preserves __bash_session__ when pruning oversized state', () => {
|
||||
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');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Cleanup only the temp directory created by this test file.
|
||||
if (!externalStateDir) {
|
||||
try {
|
||||
if (fs.existsSync(stateDir)) {
|
||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(` [cleanup] failed to remove ${stateDir}: ${err.message}`);
|
||||
// --- Test 15: raw input session IDs provide stable retry state without env vars ---
|
||||
clearState();
|
||||
if (test('uses raw input session_id when hook env vars are missing', () => {
|
||||
const input = {
|
||||
session_id: 'raw-session-1234',
|
||||
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 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`);
|
||||
|
||||
@@ -1888,6 +1888,33 @@ async function runTests() {
|
||||
passed++;
|
||||
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 (
|
||||
test('SessionEnd marker hook is async and cleanup-safe', () => {
|
||||
const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json');
|
||||
@@ -1912,13 +1939,14 @@ async function runTests() {
|
||||
for (const entry of hookArray) {
|
||||
for (const hook of entry.hooks) {
|
||||
if (hook.type === 'command') {
|
||||
const isNode = hook.command.startsWith('node');
|
||||
const isNpx = hook.command.startsWith('npx ');
|
||||
const isSkillScript = hook.command.includes('/skills/') && (/^(bash|sh)\s/.test(hook.command) || hook.command.startsWith('${CLAUDE_PLUGIN_ROOT}/skills/'));
|
||||
const isHookShellWrapper = /^(bash|sh)\s+["']?\$\{CLAUDE_PLUGIN_ROOT\}\/scripts\/hooks\/run-with-flags-shell\.sh/.test(hook.command);
|
||||
const commandText = Array.isArray(hook.command) ? hook.command.join(' ') : hook.command;
|
||||
const commandStart = Array.isArray(hook.command) ? hook.command[0] : hook.command;
|
||||
const isNode = commandStart === 'node' || (typeof commandStart === 'string' && commandStart.startsWith('node'));
|
||||
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(
|
||||
isNode || isNpx || isSkillScript || isHookShellWrapper,
|
||||
`Hook command should use node or approved shell wrapper: ${hook.command.substring(0, 100)}...`
|
||||
isNode || isNpx || isSkillScript,
|
||||
`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];
|
||||
|
||||
assert.ok(sessionStartHook, 'Should define a SessionStart hook');
|
||||
// The bootstrap was extracted to a standalone file to avoid shell history
|
||||
// expansion of `!` characters that caused startup hook errors when the
|
||||
// logic was embedded as an inline `node -e "..."` string.
|
||||
const commandText = Array.isArray(sessionStartHook.command)
|
||||
? sessionStartHook.command.join(' ')
|
||||
: sessionStartHook.command;
|
||||
assert.ok(Array.isArray(sessionStartHook.command), 'SessionStart should use argv form for cross-platform safety');
|
||||
assert.ok(
|
||||
sessionStartHook.command.includes('session-start-bootstrap.js'),
|
||||
commandText.includes('session-start-bootstrap.js'),
|
||||
'SessionStart should delegate to the extracted bootstrap script'
|
||||
);
|
||||
assert.ok(sessionStartHook.command.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(!sessionStartHook.command.includes('head -n 1'), 'Should not pick the first matching plugin path');
|
||||
assert.ok(commandText.includes('CLAUDE_PLUGIN_ROOT'), 'SessionStart should use CLAUDE_PLUGIN_ROOT');
|
||||
assert.ok(!commandText.includes('${CLAUDE_PLUGIN_ROOT}'), 'SessionStart should not depend on raw shell placeholder expansion');
|
||||
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
|
||||
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 || []);
|
||||
|
||||
for (const hook of [...stopHooks, ...sessionEndHooks]) {
|
||||
assert.ok(hook.command.startsWith('node -e "'), 'Lifecycle hook should use inline node resolver');
|
||||
assert.ok(hook.command.includes('run-with-flags.js'), 'Lifecycle hook should resolve the runner script');
|
||||
assert.ok(hook.command.includes('CLAUDE_PLUGIN_ROOT'), 'Lifecycle hook should consult CLAUDE_PLUGIN_ROOT');
|
||||
assert.ok(hook.command.includes('plugins'), 'Lifecycle hook should probe known plugin roots');
|
||||
assert.ok(!hook.command.includes('find '), 'Lifecycle hook should not scan arbitrary plugin paths with find');
|
||||
assert.ok(!hook.command.includes('head -n 1'), 'Lifecycle hook should not pick the first matching plugin path');
|
||||
const commandText = Array.isArray(hook.command) ? hook.command.join(' ') : hook.command;
|
||||
assert.ok(
|
||||
(Array.isArray(hook.command) && hook.command[0] === 'node' && hook.command[1] === '-e') ||
|
||||
(typeof hook.command === 'string' && hook.command.startsWith('node -e "')),
|
||||
'Lifecycle hook should use inline node resolver'
|
||||
);
|
||||
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++;
|
||||
else failed++;
|
||||
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 hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8'));
|
||||
|
||||
const checkHooks = hookArray => {
|
||||
for (const entry of hookArray) {
|
||||
for (const hook of entry.hooks) {
|
||||
if (hook.type === 'command' && hook.command.includes('scripts/hooks/')) {
|
||||
const usesInlineResolver = hook.command.startsWith('node -e') && hook.command.includes('run-with-flags.js');
|
||||
const hasPluginRoot = hook.command.includes('${CLAUDE_PLUGIN_ROOT}') || usesInlineResolver;
|
||||
assert.ok(hasPluginRoot, `Script paths should use CLAUDE_PLUGIN_ROOT: ${hook.command.substring(0, 80)}...`);
|
||||
const commandText = Array.isArray(hook.command) ? hook.command.join(' ') : hook.command;
|
||||
const commandStart = Array.isArray(hook.command) ? `${hook.command[0]} ${hook.command[1] || ''}`.trim() : hook.command;
|
||||
if (hook.type === 'command' && commandText.includes('scripts/hooks/')) {
|
||||
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)}...`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,24 +110,70 @@ function runHookCommand(command, input = {}, env = {}, timeoutMs = 10000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const isWindows = process.platform === 'win32';
|
||||
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(
|
||||
/\$\{([A-Z_][A-Z0-9_]*)\}/g,
|
||||
(_, name) => String(mergedEnv[name] || '')
|
||||
);
|
||||
|
||||
const nodeMatch = resolvedCommand.match(/^node\s+"([^"]+)"\s*(.*)$/);
|
||||
const useDirectNodeSpawn = Boolean(nodeMatch);
|
||||
const inlineNodeMatch = resolvedCommand.match(/^node -e "((?:[^"\\]|\\.)*)"(?:\s+(.*))?$/s);
|
||||
const fileNodeMatch = resolvedCommand.match(/^node\s+"([^"]+)"\s*(.*)$/);
|
||||
const useDirectNodeSpawn = Boolean(inlineNodeMatch || fileNodeMatch);
|
||||
const shell = isWindows ? 'cmd' : 'bash';
|
||||
const shellArgs = isWindows ? ['/d', '/s', '/c', resolvedCommand] : ['-lc', resolvedCommand];
|
||||
const nodeArgs = nodeMatch
|
||||
? [
|
||||
nodeMatch[1],
|
||||
...Array.from(
|
||||
nodeMatch[2].matchAll(/"([^"]*)"|(\S+)/g),
|
||||
m => m[1] !== undefined ? m[1] : m[2]
|
||||
)
|
||||
]
|
||||
: [];
|
||||
const splitArgs = value => Array.from(
|
||||
String(value || '').matchAll(/"([^"]*)"|(\S+)/g),
|
||||
m => m[1] !== undefined ? m[1] : m[2]
|
||||
);
|
||||
const unescapeInlineJs = value => value
|
||||
.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
|
||||
? spawn('node', nodeArgs, { env: mergedEnv, stdio: ['pipe', 'pipe', 'pipe'] })
|
||||
@@ -210,6 +256,14 @@ function getHookCommandByDescription(hooks, lifecycle, descriptionText) {
|
||||
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
|
||||
async function runTests() {
|
||||
console.log('\n=== Hook Integration Tests ===\n');
|
||||
@@ -294,12 +348,7 @@ async function runTests() {
|
||||
})) passed++; else failed++;
|
||||
|
||||
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 = getHookCommandByDescription(
|
||||
hooks,
|
||||
'PreToolUse',
|
||||
'Auto-start dev servers in tmux'
|
||||
);
|
||||
const hookCommand = getHookCommandById(hooks, 'PreToolUse', 'pre:bash:dispatcher');
|
||||
const result = await runHookCommand(hookCommand, {
|
||||
tool_input: { command: 'npm run dev' }
|
||||
});
|
||||
@@ -480,12 +529,7 @@ async function runTests() {
|
||||
})) passed++; else failed++;
|
||||
|
||||
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 = getHookCommandByDescription(
|
||||
hooks,
|
||||
'PreToolUse',
|
||||
'Auto-start dev servers in tmux'
|
||||
);
|
||||
const hookCommand = getHookCommandById(hooks, 'PreToolUse', 'pre:bash:dispatcher');
|
||||
const result = await runHookCommand(hookCommand, {
|
||||
tool_input: { command: 'yarn dev' }
|
||||
});
|
||||
@@ -617,14 +661,8 @@ async function runTests() {
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await asyncTest('PostToolUse PR hook extracts PR URL', async () => {
|
||||
// Find the PR logging hook
|
||||
const prHook = hooks.hooks.PostToolUse.find(h =>
|
||||
h.description && h.description.includes('PR URL')
|
||||
);
|
||||
|
||||
assert.ok(prHook, 'PR hook should exist');
|
||||
|
||||
const result = await runHookCommand(prHook.hooks[0].command, {
|
||||
const hookCommand = getHookCommandById(hooks, 'PostToolUse', 'post:bash:dispatcher');
|
||||
const result = await runHookCommand(hookCommand, {
|
||||
tool_input: { command: 'gh pr create --title "Test"' },
|
||||
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');
|
||||
|
||||
const command = asyncHook.hooks[0].command;
|
||||
const isNodeInline = command.startsWith('node -e');
|
||||
const isNodeScript = command.startsWith('node "');
|
||||
const commandText = Array.isArray(command) ? command.join(' ') : command;
|
||||
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 =
|
||||
command.startsWith('bash "') ||
|
||||
command.startsWith('sh "') ||
|
||||
command.startsWith('bash -lc ') ||
|
||||
command.startsWith('sh -c ');
|
||||
(Array.isArray(command) && (command[0] === 'bash' || command[0] === 'sh')) ||
|
||||
commandText.startsWith('bash "') ||
|
||||
commandText.startsWith('sh "') ||
|
||||
commandText.startsWith('bash -lc ') ||
|
||||
commandText.startsWith('sh -c ');
|
||||
assert.ok(
|
||||
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++;
|
||||
|
||||
@@ -920,19 +964,28 @@ async function runTests() {
|
||||
for (const hook of hookDef.hooks) {
|
||||
assert.ok(hook.command, `Hook in ${hookType} should have command field`);
|
||||
|
||||
const isInline = hook.command.startsWith('node -e');
|
||||
const isFilePath = hook.command.startsWith('node "');
|
||||
const isNpx = hook.command.startsWith('npx ');
|
||||
const command = hook.command;
|
||||
const commandText = Array.isArray(command) ? command.join(' ') : command;
|
||||
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 =
|
||||
hook.command.startsWith('bash "') ||
|
||||
hook.command.startsWith('sh "') ||
|
||||
hook.command.startsWith('bash -lc ') ||
|
||||
hook.command.startsWith('sh -c ');
|
||||
const isShellScriptPath = hook.command.endsWith('.sh');
|
||||
(Array.isArray(command) && (command[0] === 'bash' || command[0] === 'sh')) ||
|
||||
commandText.startsWith('bash "') ||
|
||||
commandText.startsWith('sh "') ||
|
||||
commandText.startsWith('bash -lc ') ||
|
||||
commandText.startsWith('sh -c ');
|
||||
const isShellScriptPath =
|
||||
(Array.isArray(command) && typeof command[0] === 'string' && command[0].endsWith('.sh')) ||
|
||||
commandText.endsWith('.sh');
|
||||
|
||||
assert.ok(
|
||||
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)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,11 +124,11 @@ function runTests() {
|
||||
);
|
||||
assert.ok(
|
||||
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.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++;
|
||||
|
||||
|
||||
@@ -94,14 +94,14 @@ function runTests() {
|
||||
normalizedRelativePath(operation.sourceRelativePath) === '.cursor/hooks.json'
|
||||
));
|
||||
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.strictEqual(hooksJson.strategy, 'preserve-relative-path');
|
||||
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.destinationPath,
|
||||
@@ -236,8 +236,8 @@ function runTests() {
|
||||
assert.strictEqual(commonAgentsDestinations.length, 1, 'Should keep only one common-agents.mdc operation');
|
||||
assert.strictEqual(
|
||||
normalizedRelativePath(commonAgentsDestinations[0].sourceRelativePath),
|
||||
'rules/common/agents.md',
|
||||
'Should prefer rules-core when cursor platform rules would collide'
|
||||
'.cursor/rules/common-agents.md',
|
||||
'Should prefer native .cursor/rules content when cursor platform rules would collide'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
|
||||
@@ -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 packageLock = loadJsonObject(packageLockPath, '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);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const readme = fs.readFileSync(zhCnReadmePath, 'utf8');
|
||||
const match = readme.match(/^\| \*\*版本\*\* \| 插件 \| 插件 \| 参考配置 \| ([0-9][0-9.]*) \|$/m);
|
||||
|
||||
128
tests/scripts/ecc-dashboard.test.js
Normal file
128
tests/scripts/ecc-dashboard.test.js
Normal 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();
|
||||
@@ -19,12 +19,21 @@ function cleanup(dirPath) {
|
||||
}
|
||||
|
||||
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], {
|
||||
cwd: options.cwd || path.join(__dirname, '..', '..'),
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: options.homeDir || process.env.HOME,
|
||||
},
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000,
|
||||
@@ -132,6 +141,109 @@ function runTests() {
|
||||
}
|
||||
})) 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}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
@@ -350,7 +350,7 @@ function runTests() {
|
||||
}
|
||||
})) 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 projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
@@ -361,18 +361,28 @@ function runTests() {
|
||||
const claudeRoot = path.join(homeDir, '.claude');
|
||||
const installedHooks = readJson(path.join(claudeRoot, 'hooks', 'hooks.json'));
|
||||
|
||||
const normSep = (s) => s.replace(/\\/g, '/');
|
||||
const expectedFragment = normSep(path.join(claudeRoot, 'scripts', 'hooks', 'auto-tmux-dev.js'));
|
||||
|
||||
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');
|
||||
const installedBashDispatcherEntry = installedHooks.hooks.PreToolUse.find(entry => entry.id === 'pre:bash:dispatcher');
|
||||
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');
|
||||
assert.ok(
|
||||
normSep(installedAutoTmuxEntry.hooks[0].command).includes(expectedFragment),
|
||||
'hooks/hooks.json should use the installed Claude root for hook commands'
|
||||
installedBashDispatcherEntry.hooks[0].command[0] === 'node' && installedBashDispatcherEntry.hooks[0].command[1] === '-e',
|
||||
'hooks/hooks.json should use the inline node bootstrap contract'
|
||||
);
|
||||
assert.ok(
|
||||
!installedAutoTmuxEntry.hooks[0].command.includes('${CLAUDE_PLUGIN_ROOT}'),
|
||||
'hooks/hooks.json should not retain CLAUDE_PLUGIN_ROOT placeholders after install'
|
||||
installedBashDispatcherEntry.hooks[0].command.some(part => String(part).includes('plugin-hook-bootstrap.js')),
|
||||
'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 {
|
||||
cleanup(homeDir);
|
||||
|
||||
Reference in New Issue
Block a user