27 Commits

Author SHA1 Message Date
dependabot[bot]
649fa885dc chore(deps): bump actions/cache from 5.0.4 to 5.0.5
Bumps [actions/cache](https://github.com/actions/cache) from 5.0.4 to 5.0.5.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](668228422a...27d5ce7f10)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: 5.0.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-19 04:52:37 +00:00
Affaan Mustafa
1a50145d39 Merge pull request #1462 from affaan-m/fix/remove-legacy-ecc-install-refs
fix: restore string hook commands for Claude Code schema
2026-04-15 20:07:04 -07:00
Affaan Mustafa
eb900ddd81 test: align hook install expectations with Claude schema 2026-04-15 19:33:34 -07:00
Affaan Mustafa
ccecb0b9f4 fix: restore string hook commands for Claude Code schema 2026-04-15 17:25:33 -07:00
Affaan Mustafa
9fb88c6700 Merge pull request #1449 from affaan-m/fix/bash-hook-dispatcher-forkstorm-clean
fix: consolidate bash hooks without fork storms
2026-04-15 17:23:09 -07:00
Affaan Mustafa
6b7bd7156c fix: relax pnpm strict build checks in CI 2026-04-15 16:44:58 -07:00
Affaan Mustafa
1fabf4d2cf fix: consolidate bash hooks without fork storms 2026-04-14 21:23:57 -07:00
Affaan Mustafa
7eb7c598fb Merge pull request #1448 from affaan-m/fix/manual-release-dispatch
fix: allow manual release workflow dispatch
2026-04-14 21:20:10 -07:00
Affaan Mustafa
8b5c0c1b07 fix: allow manual release workflow dispatch 2026-04-14 21:02:23 -07:00
Affaan Mustafa
c1e7a272cc Merge pull request #1446 from affaan-m/fix/release-publish-and-migration-docs
fix: publish npm releases and clarify install identifiers
2026-04-14 20:58:20 -07:00
Affaan Mustafa
5427c27930 Merge pull request #1445 from affaan-m/fix/plugin-installed-hook-root-resolution
fix: resolve plugin-installed hook root on marketplace installs
2026-04-14 20:43:40 -07:00
Affaan Mustafa
b5c4d2beb9 fix: wire npm auth into release publish 2026-04-14 20:43:22 -07:00
Affaan Mustafa
34380326c8 fix: publish npm releases and clarify install identifiers 2026-04-14 20:42:28 -07:00
Affaan Mustafa
9227d3cc30 docs: add ecc recovery guidance for wiped setups 2026-04-14 20:41:18 -07:00
Affaan Mustafa
8da668f1ac Merge pull request #1439 from affaan-m/fix/urgent-install-and-name
fix: unblock urgent install and gateguard regressions
2026-04-14 20:36:06 -07:00
Affaan Mustafa
1b7c5789fc fix: bootstrap plugin-installed hook commands safely 2026-04-14 20:24:21 -07:00
Affaan Mustafa
cdeb837838 Merge origin/main into fix/urgent-install-and-name 2026-04-14 20:23:54 -07:00
Affaan Mustafa
cca163c776 Merge pull request #1440 from affaan-m/fix/dashboard-terminal-safety
fix(dashboard): harden terminal launch and maximize behavior
2026-04-14 20:21:51 -07:00
Affaan Mustafa
c54b44edf3 test: fix harness audit env fallback 2026-04-14 20:03:57 -07:00
Affaan Mustafa
2691cfc0f1 fix: restore dashboard branch ci baseline 2026-04-14 19:54:28 -07:00
Affaan Mustafa
b2c4b7f51c Merge remote-tracking branch 'origin/main' into fix/urgent-install-and-name 2026-04-14 19:50:35 -07:00
Affaan Mustafa
c924290b5b fix: restore dashboard branch CI baseline 2026-04-14 19:46:00 -07:00
Affaan Mustafa
e46deb93c8 fix: harden dashboard terminal launch helpers 2026-04-14 19:44:32 -07:00
Affaan Mustafa
8776c4f8f3 fix: harden urgent install and gateguard patch 2026-04-14 19:44:08 -07:00
Affaan Mustafa
e5225db006 docs: sync catalog counts on urgent fix branch 2026-04-14 19:31:23 -07:00
Affaan Mustafa
3be24a5704 fix: restore urgent PR CI health 2026-04-14 19:26:24 -07:00
Affaan Mustafa
76b6e22b4d fix: unblock urgent install and gateguard regressions 2026-04-14 19:23:07 -07:00
57 changed files with 1902 additions and 1223 deletions

View File

@@ -76,7 +76,7 @@ jobs:
- name: Cache npm - name: Cache npm
if: matrix.pm == 'npm' if: matrix.pm == 'npm'
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ${{ steps.npm-cache-dir.outputs.dir }} path: ${{ steps.npm-cache-dir.outputs.dir }}
key: ${{ runner.os }}-node-${{ matrix.node }}-npm-${{ hashFiles('**/package-lock.json') }} key: ${{ runner.os }}-node-${{ matrix.node }}-npm-${{ hashFiles('**/package-lock.json') }}
@@ -93,7 +93,7 @@ jobs:
- name: Cache pnpm - name: Cache pnpm
if: matrix.pm == 'pnpm' if: matrix.pm == 'pnpm'
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ${{ steps.pnpm-cache-dir.outputs.dir }} path: ${{ steps.pnpm-cache-dir.outputs.dir }}
key: ${{ runner.os }}-node-${{ matrix.node }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} key: ${{ runner.os }}-node-${{ matrix.node }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
@@ -114,7 +114,7 @@ jobs:
- name: Cache yarn - name: Cache yarn
if: matrix.pm == 'yarn' if: matrix.pm == 'yarn'
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ${{ steps.yarn-cache-dir.outputs.dir }} path: ${{ steps.yarn-cache-dir.outputs.dir }}
key: ${{ runner.os }}-node-${{ matrix.node }}-yarn-${{ hashFiles('**/yarn.lock') }} key: ${{ runner.os }}-node-${{ matrix.node }}-yarn-${{ hashFiles('**/yarn.lock') }}
@@ -123,7 +123,7 @@ jobs:
- name: Cache bun - name: Cache bun
if: matrix.pm == 'bun' if: matrix.pm == 'bun'
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }} key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
@@ -140,7 +140,10 @@ jobs:
run: | run: |
case "${{ matrix.pm }}" in case "${{ matrix.pm }}" in
npm) npm ci ;; npm) npm ci ;;
pnpm) pnpm install --no-frozen-lockfile ;; # pnpm v10 can fail CI on ignored native build scripts
# (for example msgpackr-extract) even though this repo is Yarn-native
# and pnpm is only exercised here as a compatibility lane.
pnpm) pnpm install --config.strict-dep-builds=false --no-frozen-lockfile ;;
# Yarn Berry (v4+) removed --ignore-engines; engine checking is no longer a core feature # Yarn Berry (v4+) removed --ignore-engines; engine checking is no longer a core feature
yarn) yarn install ;; yarn) yarn install ;;
bun) bun install ;; bun) bun install ;;

View File

@@ -6,6 +6,7 @@ on:
permissions: permissions:
contents: write contents: write
id-token: write
jobs: jobs:
release: release:
@@ -22,6 +23,7 @@ jobs:
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
node-version: '20.x' node-version: '20.x'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
@@ -53,6 +55,23 @@ jobs:
- name: Verify release metadata stays in sync - name: Verify release metadata stays in sync
run: node tests/plugin-manifest.test.js run: node tests/plugin-manifest.test.js
- name: Check npm publish state
id: npm_publish_state
run: |
PACKAGE_NAME=$(node -p "require('./package.json').name")
PACKAGE_VERSION=$(node -p "require('./package.json').version")
if npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
echo "already_published=true" >> "$GITHUB_OUTPUT"
else
echo "already_published=false" >> "$GITHUB_OUTPUT"
fi
- name: Publish npm package
if: steps.npm_publish_state.outputs.already_published != 'true'
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --access public --provenance
- name: Generate release highlights - name: Generate release highlights
id: highlights id: highlights
env: env:
@@ -73,6 +92,8 @@ jobs:
- Improved release-note generation and changelog hygiene - Improved release-note generation and changelog hygiene
### Notes ### Notes
- npm package: \`ecc-universal\`
- Claude marketplace/plugin identifier: \`everything-claude-code@everything-claude-code\`
- For migration tips and compatibility notes, see README and CHANGELOG. - For migration tips and compatibility notes, see README and CHANGELOG.
EOF EOF

View File

@@ -12,9 +12,24 @@ on:
required: false required: false
type: boolean type: boolean
default: true default: true
secrets:
NPM_TOKEN:
required: false
workflow_dispatch:
inputs:
tag:
description: 'Version tag to release or republish (e.g., v1.10.0)'
required: true
type: string
generate-notes:
description: 'Auto-generate release notes'
required: false
type: boolean
default: true
permissions: permissions:
contents: write contents: write
id-token: write
jobs: jobs:
release: release:
@@ -31,6 +46,7 @@ jobs:
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
node-version: '20.x' node-version: '20.x'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
@@ -62,6 +78,23 @@ jobs:
- name: Verify release metadata stays in sync - name: Verify release metadata stays in sync
run: node tests/plugin-manifest.test.js run: node tests/plugin-manifest.test.js
- name: Check npm publish state
id: npm_publish_state
run: |
PACKAGE_NAME=$(node -p "require('./package.json').name")
PACKAGE_VERSION=$(node -p "require('./package.json').version")
if npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
echo "already_published=true" >> "$GITHUB_OUTPUT"
else
echo "already_published=false" >> "$GITHUB_OUTPUT"
fi
- name: Publish npm package
if: steps.npm_publish_state.outputs.already_published != 'true'
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --access public --provenance
- name: Generate release highlights - name: Generate release highlights
env: env:
TAG_NAME: ${{ inputs.tag }} TAG_NAME: ${{ inputs.tag }}
@@ -74,6 +107,10 @@ jobs:
- Harness reliability and cross-platform compatibility - Harness reliability and cross-platform compatibility
- Eval-driven quality improvements - Eval-driven quality improvements
- Better workflow and operator ergonomics - Better workflow and operator ergonomics
### Package Notes
- npm package: \`ecc-universal\`
- Claude marketplace/plugin identifier: \`everything-claude-code@everything-claude-code\`
EOF EOF
- name: Create GitHub Release - name: Create GitHub Release

View File

@@ -67,7 +67,7 @@ jobs:
- name: Cache npm - name: Cache npm
if: inputs.package-manager == 'npm' if: inputs.package-manager == 'npm'
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ${{ steps.npm-cache-dir.outputs.dir }} path: ${{ steps.npm-cache-dir.outputs.dir }}
key: ${{ runner.os }}-node-${{ inputs.node-version }}-npm-${{ hashFiles('**/package-lock.json') }} key: ${{ runner.os }}-node-${{ inputs.node-version }}-npm-${{ hashFiles('**/package-lock.json') }}
@@ -84,7 +84,7 @@ jobs:
- name: Cache pnpm - name: Cache pnpm
if: inputs.package-manager == 'pnpm' if: inputs.package-manager == 'pnpm'
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ${{ steps.pnpm-cache-dir.outputs.dir }} path: ${{ steps.pnpm-cache-dir.outputs.dir }}
key: ${{ runner.os }}-node-${{ inputs.node-version }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} key: ${{ runner.os }}-node-${{ inputs.node-version }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
@@ -105,7 +105,7 @@ jobs:
- name: Cache yarn - name: Cache yarn
if: inputs.package-manager == 'yarn' if: inputs.package-manager == 'yarn'
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ${{ steps.yarn-cache-dir.outputs.dir }} path: ${{ steps.yarn-cache-dir.outputs.dir }}
key: ${{ runner.os }}-node-${{ inputs.node-version }}-yarn-${{ hashFiles('**/yarn.lock') }} key: ${{ runner.os }}-node-${{ inputs.node-version }}-yarn-${{ hashFiles('**/yarn.lock') }}
@@ -114,7 +114,7 @@ jobs:
- name: Cache bun - name: Cache bun
if: inputs.package-manager == 'bun' if: inputs.package-manager == 'bun'
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }} key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
@@ -130,7 +130,10 @@ jobs:
run: | run: |
case "${{ inputs.package-manager }}" in case "${{ inputs.package-manager }}" in
npm) npm ci ;; npm) npm ci ;;
pnpm) pnpm install --no-frozen-lockfile ;; # pnpm v10 can fail CI on ignored native build scripts
# (for example msgpackr-extract) even though this repo is Yarn-native
# and pnpm is only exercised here as a compatibility lane.
pnpm) pnpm install --config.strict-dep-builds=false --no-frozen-lockfile ;;
# Yarn Berry (v4+) removed --ignore-engines; engine checking is no longer a core feature # Yarn Berry (v4+) removed --ignore-engines; engine checking is no longer a core feature
yarn) yarn install ;; yarn) yarn install ;;
bun) bun install ;; bun) bun install ;;

View File

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

View File

@@ -174,15 +174,27 @@ Get up and running in under 2 minutes:
/plugin marketplace add https://github.com/affaan-m/everything-claude-code /plugin marketplace add https://github.com/affaan-m/everything-claude-code
# Install plugin # Install plugin
/plugin install ecc@ecc /plugin install everything-claude-code@everything-claude-code
``` ```
### Naming + Migration Note
ECC now has three public identifiers, and they are not interchangeable:
- GitHub source repo: `affaan-m/everything-claude-code`
- Claude marketplace/plugin identifier: `everything-claude-code@everything-claude-code`
- npm package: `ecc-universal`
This is intentional. 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, `/plugin install`, `/plugin list`, and repo docs aligned to one public install surface. Older posts may still show the old short-form nickname; that shorthand is deprecated. Separately, the npm package stayed on `ecc-universal`, so npm installs and marketplace installs intentionally use different names.
### Step 2: Install Rules (Required) ### Step 2: Install Rules (Required)
> WARNING: **Important:** Claude Code plugins cannot distribute `rules` automatically. Install them manually: > WARNING: **Important:** Claude Code plugins cannot distribute `rules` automatically. Install them manually:
> >
> If your local Claude setup was wiped or reset, that does not mean you need to repurchase ECC. Start with `ecc list-installed`, then run `ecc doctor` and `ecc repair` before reinstalling anything. That usually restores ECC-managed files without rebuilding your setup. If the problem is account or marketplace access for ECC Tools, handle billing/account recovery separately. > If your local Claude setup was wiped or reset, that does not mean you need to repurchase ECC. Start with `ecc list-installed`, then run `ecc doctor` and `ecc repair` before reinstalling anything. That usually restores ECC-managed files without rebuilding your setup. If the problem is account or marketplace access for ECC Tools, handle billing/account recovery separately.
> If your local Claude setup was wiped or reset, that does not mean you need to repurchase ECC. Start with `ecc list-installed`, then run `ecc doctor` and `ecc repair` before reinstalling anything. That usually restores ECC-managed files without rebuilding your setup. If the problem is account or marketplace access for ECC Tools, handle billing/account recovery separately.
```bash ```bash
# Clone the repo first # Clone the repo first
git clone https://github.com/affaan-m/everything-claude-code.git git clone https://github.com/affaan-m/everything-claude-code.git
@@ -236,10 +248,10 @@ For manual install instructions see the README in the `rules/` folder. When copy
# /plan "Add user authentication" # /plan "Add user authentication"
# Check available commands # Check available commands
/plugin list ecc@ecc /plugin list everything-claude-code@everything-claude-code
``` ```
**That's it!** You now have access to 47 agents, 181 skills, and 80 legacy command shims. **That's it!** You now have access to 48 agents, 183 skills, and 79 legacy command shims.
### Dashboard GUI ### Dashboard GUI
@@ -648,7 +660,7 @@ The easiest way to use this repo - install as a Claude Code plugin:
/plugin marketplace add https://github.com/affaan-m/everything-claude-code /plugin marketplace add https://github.com/affaan-m/everything-claude-code
# Install the plugin # Install the plugin
/plugin install ecc@ecc /plugin install everything-claude-code@everything-claude-code
``` ```
Or add directly to your `~/.claude/settings.json`: Or add directly to your `~/.claude/settings.json`:
@@ -664,7 +676,7 @@ Or add directly to your `~/.claude/settings.json`:
} }
}, },
"enabledPlugins": { "enabledPlugins": {
"ecc@ecc": true "everything-claude-code@everything-claude-code": true
} }
} }
``` ```
@@ -729,7 +741,7 @@ cp everything-claude-code/commands/*.md ~/.claude/commands/
#### Install hooks #### Install hooks
Do not copy the raw repo `hooks/hooks.json` into `~/.claude/settings.json` or `~/.claude/hooks/hooks.json`. That file is plugin/repo-oriented and still contains `${CLAUDE_PLUGIN_ROOT}` placeholders, so raw copying is not a supported manual install path. Do not copy the raw repo `hooks/hooks.json` into `~/.claude/settings.json` or `~/.claude/hooks/hooks.json`. That file is plugin/repo-oriented and is meant to be installed through the ECC installer or loaded as a plugin, so raw copying is not a supported manual install path.
Use the installer to install only the Claude hook runtime so command paths are rewritten correctly: Use the installer to install only the Claude hook runtime so command paths are rewritten correctly:
@@ -745,7 +757,7 @@ pwsh -File .\install.ps1 --target claude --modules hooks-runtime
That writes resolved hooks to `~/.claude/hooks/hooks.json` and leaves any existing `~/.claude/settings.json` untouched. That writes resolved hooks to `~/.claude/hooks/hooks.json` and leaves any existing `~/.claude/settings.json` untouched.
If you installed ECC via `/plugin install`, do not copy those hooks into `settings.json`. Claude Code v2.1+ already auto-loads plugin `hooks/hooks.json`, and duplicating them in `settings.json` causes duplicate execution and `${CLAUDE_PLUGIN_ROOT}` resolution failures. If you installed ECC via `/plugin install`, do not copy those hooks into `settings.json`. Claude Code v2.1+ already auto-loads plugin `hooks/hooks.json`, and duplicating them in `settings.json` causes duplicate execution and cross-platform hook conflicts.
Windows note: the Claude config directory is `%USERPROFILE%\\.claude`, not `~/claude`. Windows note: the Claude config directory is `%USERPROFILE%\\.claude`, not `~/claude`.
@@ -882,7 +894,7 @@ Slash forms below are shown because they are still the fastest familiar entrypoi
<summary><b>How do I check which agents/commands are installed?</b></summary> <summary><b>How do I check which agents/commands are installed?</b></summary>
```bash ```bash
/plugin list ecc@ecc /plugin list everything-claude-code@everything-claude-code
``` ```
This shows all available agents, commands, and skills from the plugin. This shows all available agents, commands, and skills from the plugin.
@@ -1205,9 +1217,9 @@ The configuration is automatically detected from `.opencode/opencode.json`.
| Feature | Claude Code | OpenCode | Status | | Feature | Claude Code | OpenCode | Status |
|---------|-------------|----------|--------| |---------|-------------|----------|--------|
| Agents | PASS: 47 agents | PASS: 12 agents | **Claude Code leads** | | Agents | PASS: 48 agents | PASS: 12 agents | **Claude Code leads** |
| Commands | PASS: 80 commands | PASS: 31 commands | **Claude Code leads** | | Commands | PASS: 79 commands | PASS: 31 commands | **Claude Code leads** |
| Skills | PASS: 181 skills | PASS: 37 skills | **Claude Code leads** | | Skills | PASS: 183 skills | PASS: 37 skills | **Claude Code leads** |
| Hooks | PASS: 8 event types | PASS: 11 events | **OpenCode has more!** | | Hooks | PASS: 8 event types | PASS: 11 events | **OpenCode has more!** |
| Rules | PASS: 29 rules | PASS: 13 instructions | **Claude Code leads** | | Rules | PASS: 29 rules | PASS: 13 instructions | **Claude Code leads** |
| MCP Servers | PASS: 14 servers | PASS: Full | **Full parity** | | MCP Servers | PASS: 14 servers | PASS: Full | **Full parity** |
@@ -1314,9 +1326,9 @@ ECC is the **first plugin to maximize every major AI coding tool**. Here's how e
| Feature | Claude Code | Cursor IDE | Codex CLI | OpenCode | | Feature | Claude Code | Cursor IDE | Codex CLI | OpenCode |
|---------|------------|------------|-----------|----------| |---------|------------|------------|-----------|----------|
| **Agents** | 47 | Shared (AGENTS.md) | Shared (AGENTS.md) | 12 | | **Agents** | 48 | Shared (AGENTS.md) | Shared (AGENTS.md) | 12 |
| **Commands** | 80 | Shared | Instruction-based | 31 | | **Commands** | 79 | Shared | Instruction-based | 31 |
| **Skills** | 181 | Shared | 10 (native format) | 37 | | **Skills** | 183 | Shared | 10 (native format) | 37 |
| **Hook Events** | 8 types | 15 types | None yet | 11 types | | **Hook Events** | 8 types | 15 types | None yet | 11 types |
| **Hook Scripts** | 20+ scripts | 16 scripts (DRY adapter) | N/A | Plugin hooks | | **Hook Scripts** | 20+ scripts | 16 scripts (DRY adapter) | N/A | Plugin hooks |
| **Rules** | 34 (common + lang) | 34 (YAML frontmatter) | Instruction-based | 13 instructions | | **Rules** | 34 (common + lang) | 34 (YAML frontmatter) | Instruction-based | 13 instructions |

View File

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

View File

@@ -146,7 +146,6 @@ skills:
commands: commands:
- agent-sort - agent-sort
- aside - aside
- auto-update
- build-fix - build-fix
- checkpoint - checkpoint
- claw - claw

View File

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

View File

@@ -1,28 +0,0 @@
---
description: Pull the latest ECC repo changes and reinstall the current managed targets.
disable-model-invocation: true
---
# Auto Update
Update ECC from its upstream repo and regenerate the current context's managed install using the original install-state request.
## Usage
```bash
# Preview the update without mutating anything
ECC_ROOT="${CLAUDE_PLUGIN_ROOT:-$(node -e "var 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})();console.log(r)")}"
node "$ECC_ROOT/scripts/auto-update.js" --dry-run
# Update only Cursor-managed files in the current project
node "$ECC_ROOT/scripts/auto-update.js" --target cursor
# Override the ECC repo root explicitly
node "$ECC_ROOT/scripts/auto-update.js" --repo-root /path/to/everything-claude-code
```
## Notes
- This command uses the recorded install-state request and reruns `install-apply.js` after pulling the latest repo changes.
- Reinstall is intentional: it handles upstream renames and deletions that `repair.js` cannot safely reconstruct from stale operations alone.
- Use `--dry-run` first if you want to see the reconstructed reinstall plan before mutating anything.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -90,7 +90,6 @@
".gemini", ".gemini",
".opencode", ".opencode",
"mcp-configs", "mcp-configs",
"scripts/auto-update.js",
"scripts/setup-package-manager.js" "scripts/setup-package-manager.js"
], ],
"targets": [ "targets": [

View File

@@ -2,6 +2,9 @@
"name": "ecc-universal", "name": "ecc-universal",
"version": "1.10.0", "version": "1.10.0",
"description": "Complete collection of battle-tested Claude Code configs — agents, skills, hooks, rules, and legacy command shims evolved over 10+ months of intensive daily use by an Anthropic hackathon winner", "description": "Complete collection of battle-tested Claude Code configs — agents, skills, hooks, rules, and legacy command shims evolved over 10+ months of intensive daily use by an Anthropic hackathon winner",
"publishConfig": {
"access": "public"
},
"keywords": [ "keywords": [
"claude-code", "claude-code",
"ai", "ai",

View File

@@ -1,361 +0,0 @@
#!/usr/bin/env node
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const { discoverInstalledStates } = require('./lib/install-lifecycle');
const { SUPPORTED_INSTALL_TARGETS } = require('./lib/install-manifests');
function showHelp(exitCode = 0) {
console.log(`
Usage: node scripts/auto-update.js [--target <${SUPPORTED_INSTALL_TARGETS.join('|')}>] [--repo-root <path>] [--dry-run] [--json]
Pull the latest ECC repo changes and reinstall the current context's managed targets
using the original install-state request.
`);
process.exit(exitCode);
}
function parseArgs(argv) {
const args = argv.slice(2);
const parsed = {
targets: [],
repoRoot: null,
dryRun: false,
json: false,
help: false,
};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === '--target') {
parsed.targets.push(args[index + 1] || null);
index += 1;
} else if (arg === '--repo-root') {
parsed.repoRoot = args[index + 1] || null;
index += 1;
} else if (arg === '--dry-run') {
parsed.dryRun = true;
} else if (arg === '--json') {
parsed.json = true;
} else if (arg === '--help' || arg === '-h') {
parsed.help = true;
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
return parsed;
}
function deriveRepoRootFromState(state) {
const operations = Array.isArray(state && state.operations) ? state.operations : [];
for (const operation of operations) {
if (typeof operation.sourcePath !== 'string' || !operation.sourcePath.trim()) {
continue;
}
if (typeof operation.sourceRelativePath !== 'string' || !operation.sourceRelativePath.trim()) {
continue;
}
const relativeParts = operation.sourceRelativePath
.split(/[\\/]+/)
.filter(Boolean);
if (relativeParts.length === 0) {
continue;
}
let repoRoot = path.resolve(operation.sourcePath);
for (let index = 0; index < relativeParts.length; index += 1) {
repoRoot = path.dirname(repoRoot);
}
return repoRoot;
}
throw new Error('Unable to infer ECC repo root from install-state operations');
}
function buildInstallApplyArgs(record) {
const state = record.state;
const target = state.target.target || record.adapter.target;
const request = state.request || {};
const args = [];
if (target) {
args.push('--target', target);
}
if (request.profile) {
args.push('--profile', request.profile);
}
if (Array.isArray(request.modules) && request.modules.length > 0) {
args.push('--modules', request.modules.join(','));
}
for (const componentId of Array.isArray(request.includeComponents) ? request.includeComponents : []) {
args.push('--with', componentId);
}
for (const componentId of Array.isArray(request.excludeComponents) ? request.excludeComponents : []) {
args.push('--without', componentId);
}
for (const language of Array.isArray(request.legacyLanguages) ? request.legacyLanguages : []) {
args.push(language);
}
return args;
}
function determineInstallCwd(record, repoRoot) {
if (record.adapter.kind === 'project') {
return path.dirname(record.state.target.root);
}
return repoRoot;
}
function validateRepoRoot(repoRoot) {
const normalized = path.resolve(repoRoot);
const packageJsonPath = path.join(normalized, 'package.json');
const installApplyPath = path.join(normalized, 'scripts', 'install-apply.js');
if (!fs.existsSync(packageJsonPath)) {
throw new Error(`Invalid ECC repo root: missing package.json at ${packageJsonPath}`);
}
if (!fs.existsSync(installApplyPath)) {
throw new Error(`Invalid ECC repo root: missing install script at ${installApplyPath}`);
}
return normalized;
}
function runExternalCommand(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd,
env: options.env || process.env,
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024,
});
if (result.error) {
throw result.error;
}
if (typeof result.status === 'number' && result.status !== 0) {
const errorOutput = (result.stderr || result.stdout || '').trim();
throw new Error(`${command} ${args.join(' ')} failed${errorOutput ? `: ${errorOutput}` : ''}`);
}
return result;
}
function runAutoUpdate(options = {}, dependencies = {}) {
const discover = dependencies.discoverInstalledStates || discoverInstalledStates;
const execute = dependencies.runExternalCommand || runExternalCommand;
const homeDir = options.homeDir || process.env.HOME || os.homedir();
const projectRoot = options.projectRoot || process.cwd();
const requestedRepoRoot = options.repoRoot ? validateRepoRoot(options.repoRoot) : null;
const records = discover({
homeDir,
projectRoot,
targets: options.targets,
}).filter(record => record.exists);
const results = [];
if (records.length === 0) {
return {
dryRun: Boolean(options.dryRun),
repoRoot: requestedRepoRoot,
results,
summary: {
checkedCount: 0,
updatedCount: 0,
errorCount: 0,
},
};
}
const validRecords = [];
const inferredRepoRoots = [];
for (const record of records) {
if (record.error || !record.state) {
results.push({
adapter: record.adapter,
installStatePath: record.installStatePath,
status: 'error',
error: record.error || 'No valid install-state available',
});
continue;
}
const recordRepoRoot = requestedRepoRoot || validateRepoRoot(deriveRepoRootFromState(record.state));
inferredRepoRoots.push(recordRepoRoot);
validRecords.push({
record,
repoRoot: recordRepoRoot,
});
}
if (!requestedRepoRoot) {
const uniqueRepoRoots = [...new Set(inferredRepoRoots)];
if (uniqueRepoRoots.length > 1) {
throw new Error(`Multiple ECC repo roots detected: ${uniqueRepoRoots.join(', ')}`);
}
}
const repoRoot = requestedRepoRoot || inferredRepoRoots[0] || null;
if (!repoRoot) {
return {
dryRun: Boolean(options.dryRun),
repoRoot,
results,
summary: {
checkedCount: results.length,
updatedCount: 0,
errorCount: results.length,
},
};
}
const env = {
...process.env,
HOME: homeDir,
USERPROFILE: homeDir,
};
if (!options.dryRun) {
execute('git', ['fetch', '--all', '--prune'], { cwd: repoRoot, env });
execute('git', ['pull', '--ff-only'], { cwd: repoRoot, env });
}
for (const entry of validRecords) {
const installArgs = buildInstallApplyArgs(entry.record);
const args = [
path.join(repoRoot, 'scripts', 'install-apply.js'),
...installArgs,
'--json',
];
if (options.dryRun) {
args.push('--dry-run');
}
try {
const commandResult = execute(process.execPath, args, {
cwd: determineInstallCwd(entry.record, repoRoot),
env,
});
let payload = null;
if (commandResult.stdout && commandResult.stdout.trim()) {
payload = JSON.parse(commandResult.stdout);
}
results.push({
adapter: entry.record.adapter,
installStatePath: entry.record.installStatePath,
repoRoot,
cwd: determineInstallCwd(entry.record, repoRoot),
installArgs,
status: options.dryRun ? 'planned' : 'updated',
payload,
});
} catch (error) {
results.push({
adapter: entry.record.adapter,
installStatePath: entry.record.installStatePath,
repoRoot,
installArgs,
status: 'error',
error: error.message,
});
}
}
return {
dryRun: Boolean(options.dryRun),
repoRoot,
results,
summary: {
checkedCount: results.length,
updatedCount: results.filter(result => result.status === 'updated' || result.status === 'planned').length,
errorCount: results.filter(result => result.status === 'error').length,
},
};
}
function printHuman(result) {
if (result.results.length === 0) {
console.log('No ECC install-state files found for the current home/project context.');
return;
}
console.log(`${result.dryRun ? 'Auto-update dry run' : 'Auto-update summary'}:\n`);
if (result.repoRoot) {
console.log(`Repo root: ${result.repoRoot}\n`);
}
for (const entry of result.results) {
console.log(`- ${entry.adapter.id}`);
console.log(` Status: ${entry.status.toUpperCase()}`);
console.log(` Install-state: ${entry.installStatePath}`);
if (entry.error) {
console.log(` Error: ${entry.error}`);
continue;
}
console.log(` Reinstall args: ${entry.installArgs.join(' ') || '(none)'}`);
}
console.log(`\nSummary: checked=${result.summary.checkedCount}, ${result.dryRun ? 'planned' : 'updated'}=${result.summary.updatedCount}, errors=${result.summary.errorCount}`);
}
function main() {
try {
const options = parseArgs(process.argv);
if (options.help) {
showHelp(0);
}
const result = runAutoUpdate({
homeDir: process.env.HOME || os.homedir(),
projectRoot: process.cwd(),
targets: options.targets,
repoRoot: options.repoRoot,
dryRun: options.dryRun,
});
if (options.json) {
console.log(JSON.stringify(result, null, 2));
} else {
printHuman(result);
}
process.exitCode = result.summary.errorCount > 0 ? 1 : 0;
} catch (error) {
console.error(`Error: ${error.message}`);
process.exit(1);
}
}
if (require.main === module) {
main();
}
module.exports = {
parseArgs,
deriveRepoRootFromState,
buildInstallApplyArgs,
determineInstallCwd,
runAutoUpdate,
};

View File

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

View File

@@ -33,10 +33,6 @@ const COMMANDS = {
script: 'repair.js', script: 'repair.js',
description: 'Restore drifted or missing ECC-managed files', description: 'Restore drifted or missing ECC-managed files',
}, },
'auto-update': {
script: 'auto-update.js',
description: 'Pull latest ECC changes and reinstall the current managed targets',
},
status: { status: {
script: 'status.js', script: 'status.js',
description: 'Query the ECC SQLite state store status summary', description: 'Query the ECC SQLite state store status summary',
@@ -62,7 +58,6 @@ const PRIMARY_COMMANDS = [
'list-installed', 'list-installed',
'doctor', 'doctor',
'repair', 'repair',
'auto-update',
'status', 'status',
'sessions', 'sessions',
'session-inspect', 'session-inspect',
@@ -95,7 +90,6 @@ Examples:
ecc list-installed --json ecc list-installed --json
ecc doctor --target cursor ecc doctor --target cursor
ecc repair --dry-run ecc repair --dry-run
ecc auto-update --dry-run
ecc status --json ecc status --json
ecc sessions ecc sessions
ecc sessions session-active --json ecc sessions session-active --json

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,51 @@
'use strict';
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..', '..');
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(`${name}`);
passed++;
} catch (error) {
console.log(`${name}`);
console.log(` Error: ${error.message}`);
failed++;
}
}
const publicInstallDocs = [
'README.md',
'README.zh-CN.md',
'docs/pt-BR/README.md',
'docs/ja-JP/skills/configure-ecc/SKILL.md',
'docs/zh-CN/skills/configure-ecc/SKILL.md',
];
console.log('\n=== Testing public install identifiers ===\n');
for (const relativePath of publicInstallDocs) {
const content = fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
test(`${relativePath} does not use the stale ecc@ecc plugin identifier`, () => {
assert.ok(!content.includes('ecc@ecc'));
});
test(`${relativePath} documents the canonical marketplace plugin identifier`, () => {
assert.ok(content.includes('everything-claude-code@everything-claude-code'));
});
}
if (failed > 0) {
console.log(`\nFailed: ${failed}`);
process.exit(1);
}
console.log(`\nPassed: ${passed}`);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,395 +0,0 @@
/**
* Tests for scripts/auto-update.js
*/
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const {
parseArgs,
deriveRepoRootFromState,
buildInstallApplyArgs,
determineInstallCwd,
runAutoUpdate,
} = require('../../scripts/auto-update');
const {
createInstallState,
} = require('../../scripts/lib/install-state');
function test(name, fn) {
try {
fn();
console.log(` \u2713 ${name}`);
return true;
} catch (error) {
console.log(` \u2717 ${name}`);
console.log(` Error: ${error.message}`);
return false;
}
}
function createTempDir(prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
function cleanup(dirPath) {
fs.rmSync(dirPath, { recursive: true, force: true });
}
function makeRecord({ repoRoot, homeDir, projectRoot, adapter, request, resolution, operations }) {
const targetRoot = adapter.kind === 'project'
? path.join(projectRoot, `.${adapter.target}`)
: path.join(homeDir, '.claude');
const installStatePath = adapter.kind === 'project'
? path.join(targetRoot, 'ecc-install-state.json')
: path.join(targetRoot, 'ecc', 'install-state.json');
const state = createInstallState({
adapter,
targetRoot,
installStatePath,
request,
resolution,
operations,
source: {
repoVersion: '1.10.0',
repoCommit: 'abc123',
manifestVersion: 1,
},
});
return {
adapter,
targetRoot,
installStatePath,
exists: true,
state,
error: null,
repoRoot,
};
}
function ensureFakeRepo(repoRoot) {
fs.mkdirSync(path.join(repoRoot, 'scripts'), { recursive: true });
fs.writeFileSync(
path.join(repoRoot, 'package.json'),
JSON.stringify({ name: 'everything-claude-code', version: '1.10.0' }, null, 2)
);
fs.writeFileSync(path.join(repoRoot, 'scripts', 'install-apply.js'), '#!/usr/bin/env node\n');
}
function runTests() {
console.log('\n=== Testing auto-update.js ===\n');
let passed = 0;
let failed = 0;
if (test('parseArgs reads repo-root, target, dry-run, and json flags', () => {
const parsed = parseArgs([
'node',
'scripts/auto-update.js',
'--target',
'cursor',
'--repo-root',
'/tmp/ecc',
'--dry-run',
'--json',
]);
assert.deepStrictEqual(parsed.targets, ['cursor']);
assert.strictEqual(parsed.repoRoot, '/tmp/ecc');
assert.strictEqual(parsed.dryRun, true);
assert.strictEqual(parsed.json, true);
})) passed += 1; else failed += 1;
if (test('parseArgs rejects unknown arguments', () => {
assert.throws(
() => parseArgs(['node', 'scripts/auto-update.js', '--bogus']),
/Unknown argument: --bogus/
);
})) passed += 1; else failed += 1;
if (test('deriveRepoRootFromState uses sourcePath and sourceRelativePath', () => {
const state = {
operations: [
{
sourcePath: path.join('/tmp', 'ecc', 'scripts', 'setup-package-manager.js'),
sourceRelativePath: path.join('scripts', 'setup-package-manager.js'),
},
],
};
assert.strictEqual(
deriveRepoRootFromState(state),
path.resolve(path.join('/tmp', 'ecc'))
);
})) passed += 1; else failed += 1;
if (test('deriveRepoRootFromState fails when source metadata is unavailable', () => {
assert.throws(
() => deriveRepoRootFromState({ operations: [{ destinationPath: '/tmp/file' }] }),
/Unable to infer ECC repo root/
);
})) passed += 1; else failed += 1;
if (test('buildInstallApplyArgs reconstructs legacy installs', () => {
const record = {
adapter: { target: 'claude', kind: 'home' },
state: {
target: { target: 'claude' },
request: {
profile: null,
modules: [],
includeComponents: [],
excludeComponents: [],
legacyLanguages: ['typescript', 'python'],
legacyMode: true,
},
},
};
assert.deepStrictEqual(buildInstallApplyArgs(record), [
'--target', 'claude',
'typescript',
'python',
]);
})) passed += 1; else failed += 1;
if (test('buildInstallApplyArgs reconstructs manifest installs', () => {
const record = {
adapter: { target: 'cursor', kind: 'project' },
state: {
target: { target: 'cursor' },
request: {
profile: 'developer',
modules: ['platform-configs'],
includeComponents: ['component:alpha'],
excludeComponents: ['component:beta'],
legacyLanguages: [],
legacyMode: false,
},
},
};
assert.deepStrictEqual(buildInstallApplyArgs(record), [
'--target', 'cursor',
'--profile', 'developer',
'--modules', 'platform-configs',
'--with', 'component:alpha',
'--without', 'component:beta',
]);
})) passed += 1; else failed += 1;
if (test('determineInstallCwd uses the project root for project installs', () => {
const record = {
adapter: { kind: 'project' },
state: {
target: {
root: path.join('/tmp', 'project', '.cursor'),
},
},
};
assert.strictEqual(determineInstallCwd(record, '/tmp/ecc'), path.join('/tmp', 'project'));
})) passed += 1; else failed += 1;
if (test('runAutoUpdate reports when no install-state files are present', () => {
const result = runAutoUpdate(
{
homeDir: '/tmp/home',
projectRoot: '/tmp/project',
dryRun: true,
},
{
discoverInstalledStates: () => [],
}
);
assert.strictEqual(result.results.length, 0);
assert.strictEqual(result.summary.checkedCount, 0);
assert.strictEqual(result.summary.errorCount, 0);
})) passed += 1; else failed += 1;
if (test('runAutoUpdate rejects mixed inferred repo roots', () => {
const homeDir = createTempDir('auto-update-home-');
const projectRoot = createTempDir('auto-update-project-');
const repoOne = createTempDir('auto-update-repo-');
const repoTwo = createTempDir('auto-update-repo-');
try {
ensureFakeRepo(repoOne);
ensureFakeRepo(repoTwo);
const records = [
makeRecord({
repoRoot: repoOne,
homeDir,
projectRoot,
adapter: { id: 'claude-home', target: 'claude', kind: 'home' },
request: {
profile: null,
modules: [],
includeComponents: [],
excludeComponents: [],
legacyLanguages: ['typescript'],
legacyMode: true,
},
resolution: { selectedModules: ['legacy-claude-rules'], skippedModules: [] },
operations: [
{
kind: 'copy-file',
moduleId: 'legacy-claude-rules',
sourcePath: path.join(repoOne, 'rules', 'common', 'coding-style.md'),
sourceRelativePath: path.join('rules', 'common', 'coding-style.md'),
destinationPath: path.join(homeDir, '.claude', 'rules', 'common', 'coding-style.md'),
strategy: 'preserve-relative-path',
ownership: 'managed',
scaffoldOnly: false,
},
],
}),
makeRecord({
repoRoot: repoTwo,
homeDir,
projectRoot,
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
request: {
profile: 'core',
modules: [],
includeComponents: [],
excludeComponents: [],
legacyLanguages: [],
legacyMode: false,
},
resolution: { selectedModules: ['rules-core'], skippedModules: [] },
operations: [
{
kind: 'copy-file',
moduleId: 'rules-core',
sourcePath: path.join(repoTwo, '.cursor', 'mcp.json'),
sourceRelativePath: path.join('.cursor', 'mcp.json'),
destinationPath: path.join(projectRoot, '.cursor', 'mcp.json'),
strategy: 'sync-root-children',
ownership: 'managed',
scaffoldOnly: false,
},
],
}),
];
assert.throws(
() => runAutoUpdate(
{
homeDir,
projectRoot,
dryRun: true,
},
{
discoverInstalledStates: () => records,
}
),
/Multiple ECC repo roots detected/
);
} finally {
cleanup(homeDir);
cleanup(projectRoot);
cleanup(repoOne);
cleanup(repoTwo);
}
})) passed += 1; else failed += 1;
if (test('runAutoUpdate fetches, pulls, and reinstalls using reconstructed args', () => {
const homeDir = createTempDir('auto-update-home-');
const projectRoot = createTempDir('auto-update-project-');
const repoRoot = createTempDir('auto-update-repo-');
try {
ensureFakeRepo(repoRoot);
const records = [
makeRecord({
repoRoot,
homeDir,
projectRoot,
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
request: {
profile: 'developer',
modules: [],
includeComponents: ['component:alpha'],
excludeComponents: ['component:beta'],
legacyLanguages: [],
legacyMode: false,
},
resolution: { selectedModules: ['rules-core'], skippedModules: [] },
operations: [
{
kind: 'copy-file',
moduleId: 'platform-configs',
sourcePath: path.join(repoRoot, '.cursor', 'mcp.json'),
sourceRelativePath: path.join('.cursor', 'mcp.json'),
destinationPath: path.join(projectRoot, '.cursor', 'mcp.json'),
strategy: 'sync-root-children',
ownership: 'managed',
scaffoldOnly: false,
},
],
}),
];
const commands = [];
const result = runAutoUpdate(
{
homeDir,
projectRoot,
dryRun: false,
},
{
discoverInstalledStates: () => records,
runExternalCommand: (command, args, options) => {
commands.push({ command, args, options });
if (command === process.execPath) {
return {
stdout: JSON.stringify({
dryRun: false,
result: {
installStatePath: path.join(projectRoot, '.cursor', 'ecc-install-state.json'),
},
}),
stderr: '',
};
}
return { stdout: '', stderr: '' };
},
}
);
assert.strictEqual(result.summary.checkedCount, 1);
assert.strictEqual(result.summary.updatedCount, 1);
assert.deepStrictEqual(commands.map(entry => [entry.command, entry.args[0]]), [
['git', 'fetch'],
['git', 'pull'],
[process.execPath, path.join(repoRoot, 'scripts', 'install-apply.js')],
]);
assert.deepStrictEqual(commands[2].args.slice(1), [
'--target', 'cursor',
'--profile', 'developer',
'--with', 'component:alpha',
'--without', 'component:beta',
'--json',
]);
assert.strictEqual(commands[2].options.cwd, projectRoot);
} finally {
cleanup(homeDir);
cleanup(projectRoot);
cleanup(repoRoot);
}
})) passed += 1; else failed += 1;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
runTests();

View File

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

View File

@@ -68,7 +68,6 @@ function main() {
assert.match(result.stdout, /catalog/); assert.match(result.stdout, /catalog/);
assert.match(result.stdout, /list-installed/); assert.match(result.stdout, /list-installed/);
assert.match(result.stdout, /doctor/); assert.match(result.stdout, /doctor/);
assert.match(result.stdout, /auto-update/);
}], }],
['delegates explicit install command', () => { ['delegates explicit install command', () => {
const result = runCli(['install', '--dry-run', '--json', 'typescript']); const result = runCli(['install', '--dry-run', '--json', 'typescript']);
@@ -113,17 +112,6 @@ function main() {
const payload = parseJson(result.stdout); const payload = parseJson(result.stdout);
assert.deepStrictEqual(payload.records, []); assert.deepStrictEqual(payload.records, []);
}], }],
['delegates auto-update command', () => {
const homeDir = createTempDir('ecc-cli-home-');
const projectRoot = createTempDir('ecc-cli-project-');
const result = runCli(['auto-update', '--dry-run', '--json'], {
cwd: projectRoot,
env: { HOME: homeDir },
});
assert.strictEqual(result.status, 0, result.stderr);
const payload = parseJson(result.stdout);
assert.deepStrictEqual(payload.results, []);
}],
['delegates session-inspect command', () => { ['delegates session-inspect command', () => {
const homeDir = createTempDir('ecc-cli-home-'); const homeDir = createTempDir('ecc-cli-home-');
const sessionsDir = path.join(homeDir, '.claude', 'sessions'); const sessionsDir = path.join(homeDir, '.claude', 'sessions');
@@ -147,11 +135,6 @@ function main() {
assert.strictEqual(result.status, 0, result.stderr); assert.strictEqual(result.status, 0, result.stderr);
assert.match(result.stdout, /Usage: node scripts\/repair\.js/); assert.match(result.stdout, /Usage: node scripts\/repair\.js/);
}], }],
['supports help for the auto-update subcommand', () => {
const result = runCli(['help', 'auto-update']);
assert.strictEqual(result.status, 0, result.stderr);
assert.match(result.stdout, /Usage: node scripts\/auto-update\.js/);
}],
['supports help for the catalog subcommand', () => { ['supports help for the catalog subcommand', () => {
const result = runCli(['help', 'catalog']); const result = runCli(['help', 'catalog']);
assert.strictEqual(result.status, 0, result.stderr); assert.strictEqual(result.status, 0, result.stderr);

View File

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

View File

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

View File

@@ -0,0 +1,60 @@
'use strict';
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..', '..');
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(`${name}`);
passed++;
} catch (error) {
console.log(`${name}`);
console.log(` Error: ${error.message}`);
failed++;
}
}
function load(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
}
console.log('\n=== Testing release publish workflow ===\n');
for (const workflow of [
'.github/workflows/release.yml',
'.github/workflows/reusable-release.yml',
]) {
const content = load(workflow);
test(`${workflow} grants id-token for npm provenance`, () => {
assert.match(content, /permissions:\s*[\s\S]*id-token:\s*write/m);
});
test(`${workflow} configures the npm registry`, () => {
assert.match(content, /registry-url:\s*['"]https:\/\/registry\.npmjs\.org['"]/);
});
test(`${workflow} checks whether the tagged npm version already exists`, () => {
assert.match(content, /Check npm publish state/);
assert.match(content, /npm view "\$\{PACKAGE_NAME\}@\$\{PACKAGE_VERSION\}" version/);
});
test(`${workflow} publishes new tag versions to npm`, () => {
assert.match(content, /npm publish --access public --provenance/);
assert.match(content, /NODE_AUTH_TOKEN:\s*\$\{\{\s*secrets\.NPM_TOKEN\s*\}\}/);
});
}
if (failed > 0) {
console.log(`\nFailed: ${failed}`);
process.exit(1);
}
console.log(`\nPassed: ${passed}`);