mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-05-18 14:53:05 +08:00
Compare commits
88 Commits
28f80b69dd
...
a939236832
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a939236832 | ||
|
|
e0ddb331f6 | ||
|
|
85e331e49a | ||
|
|
5eedc8adb4 | ||
|
|
c64cc69eb2 | ||
|
|
6c67566767 | ||
|
|
deb3b1dc14 | ||
|
|
2e44beabc1 | ||
|
|
e2b5353fec | ||
|
|
9ae51bc3c1 | ||
|
|
7f7e2c2c52 | ||
|
|
7a33b2b3c9 | ||
|
|
dd2962ee92 | ||
|
|
5c4570baa5 | ||
|
|
1a950e4f83 | ||
|
|
8cd6378c81 | ||
|
|
ef7613c526 | ||
|
|
a0a1eda8fc | ||
|
|
bd207aabe1 | ||
|
|
4dbed5ff5b | ||
|
|
6eadf786f5 | ||
|
|
9e607ebb30 | ||
|
|
30f6ae4253 | ||
|
|
c826305060 | ||
|
|
db8247d701 | ||
|
|
adb46a95a6 | ||
|
|
48e5a1fa75 | ||
|
|
2fb041c6de | ||
|
|
7374ef6a73 | ||
|
|
bd2aec48ed | ||
|
|
6dc6b9266a | ||
|
|
5540282dcb | ||
|
|
67256194a0 | ||
|
|
5b0e123c10 | ||
|
|
bb96fdc9dc | ||
|
|
6ed1c643e7 | ||
|
|
0fcb43ea90 | ||
|
|
133e881ce0 | ||
|
|
45823fcede | ||
|
|
18c90a7a17 | ||
|
|
9da8e5f6ac | ||
|
|
3792b69a38 | ||
|
|
a2ad68e7e6 | ||
|
|
1b17c5c9d8 | ||
|
|
94e8f29d19 | ||
|
|
de8a7dfef8 | ||
|
|
2b09308224 | ||
|
|
5f55484fa9 | ||
|
|
e29da39eaf | ||
|
|
f4c7aac5b8 | ||
|
|
b749f5d772 | ||
|
|
2ece2cfc90 | ||
|
|
28edd197c2 | ||
|
|
fc5921a521 | ||
|
|
809e0fa0a9 | ||
|
|
dae663d856 | ||
|
|
6a247d4c43 | ||
|
|
92e5b4d415 | ||
|
|
9a64e0d271 | ||
|
|
b6a290d061 | ||
|
|
96139b2dad | ||
|
|
8a2d13187c | ||
|
|
813755b879 | ||
|
|
74b91cb3f3 | ||
|
|
d39a8a049a | ||
|
|
35aa02c645 | ||
|
|
fd0cde69d8 | ||
|
|
0f6d06d779 | ||
|
|
c277b19ee8 | ||
|
|
5736b3b684 | ||
|
|
6691e7cc9e | ||
|
|
a7f73576a9 | ||
|
|
7fc44c91b8 | ||
|
|
bc42a34e9a | ||
|
|
f53a89ff88 | ||
|
|
626c18f4c7 | ||
|
|
2d044b8032 | ||
|
|
57de4129da | ||
|
|
5ae63b301f | ||
|
|
4b92288a27 | ||
|
|
45faeb90a7 | ||
|
|
51abaf0fc0 | ||
|
|
5a03922934 | ||
|
|
33673fb37a | ||
|
|
228be4f8b8 | ||
|
|
643d03575a | ||
|
|
aa8948d5cf | ||
|
|
50dc4b0492 |
@@ -6,6 +6,7 @@
|
||||
"plugins": [
|
||||
{
|
||||
"name": "ecc",
|
||||
"version": "1.10.0",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "../.."
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "ecc",
|
||||
"name": "everything-claude-code",
|
||||
"owner": {
|
||||
"name": "Affaan Mustafa",
|
||||
"email": "me@affaanmustafa.com"
|
||||
@@ -9,7 +9,7 @@
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "ecc",
|
||||
"name": "everything-claude-code",
|
||||
"source": "./",
|
||||
"description": "The most comprehensive Claude Code plugin — 38 agents, 156 skills, 72 legacy command shims, selective install profiles, and production-ready hooks for TDD, security scanning, code review, and continuous learning",
|
||||
"version": "1.10.0",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "ecc",
|
||||
"name": "everything-claude-code",
|
||||
"version": "1.10.0",
|
||||
"description": "Battle-tested Claude Code plugin for engineering teams — 38 agents, 156 skills, 72 legacy command shims, production-ready hooks, and selective install workflows evolved through continuous real-world use",
|
||||
"author": {
|
||||
|
||||
22
.github/workflows/ci.yml
vendored
22
.github/workflows/ci.yml
vendored
@@ -43,10 +43,18 @@ jobs:
|
||||
|
||||
# Package manager setup
|
||||
- name: Setup pnpm
|
||||
if: matrix.pm == 'pnpm'
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4
|
||||
if: matrix.pm == 'pnpm' && matrix.node != '18.x'
|
||||
uses: pnpm/action-setup@08c4be7e2e672a47d11bd04269e27e5f3e8529cb # v6.0.0
|
||||
with:
|
||||
version: latest
|
||||
# Keep an explicit pnpm major because this repo's packageManager is Yarn.
|
||||
version: 10
|
||||
|
||||
- name: Setup pnpm (via Corepack)
|
||||
if: matrix.pm == 'pnpm' && matrix.node == '18.x'
|
||||
shell: bash
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@9 --activate
|
||||
|
||||
- name: Setup Yarn (via Corepack)
|
||||
if: matrix.pm == 'yarn'
|
||||
@@ -79,6 +87,8 @@ jobs:
|
||||
if: matrix.pm == 'pnpm'
|
||||
id: pnpm-cache-dir
|
||||
shell: bash
|
||||
env:
|
||||
COREPACK_ENABLE_STRICT: '0'
|
||||
run: echo "dir=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache pnpm
|
||||
@@ -146,7 +156,7 @@ jobs:
|
||||
# Upload test artifacts on failure
|
||||
- name: Upload test artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-results-${{ matrix.os }}-node${{ matrix.node }}-${{ matrix.pm }}
|
||||
path: |
|
||||
@@ -190,6 +200,10 @@ jobs:
|
||||
run: node scripts/ci/validate-install-manifests.js
|
||||
continue-on-error: false
|
||||
|
||||
- name: Validate workflow security
|
||||
run: node scripts/ci/validate-workflow-security.js
|
||||
continue-on-error: false
|
||||
|
||||
- name: Validate rules
|
||||
run: node scripts/ci/validate-rules.js
|
||||
continue-on-error: false
|
||||
|
||||
2
.github/workflows/monthly-metrics.yml
vendored
2
.github/workflows/monthly-metrics.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Update monthly metrics issue
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
|
||||
13
.github/workflows/release.yml
vendored
13
.github/workflows/release.yml
vendored
@@ -38,18 +38,21 @@ jobs:
|
||||
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
- name: Verify plugin.json version matches tag
|
||||
- name: Verify package version matches tag
|
||||
env:
|
||||
TAG_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
TAG_VERSION="${TAG_NAME#v}"
|
||||
PLUGIN_VERSION=$(grep -oE '"version": *"[^"]*"' .claude-plugin/plugin.json | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')
|
||||
if [ "$TAG_VERSION" != "$PLUGIN_VERSION" ]; then
|
||||
echo "::error::Tag version ($TAG_VERSION) does not match plugin.json version ($PLUGIN_VERSION)"
|
||||
PACKAGE_VERSION=$(node -p "require('./package.json').version")
|
||||
if [ "$TAG_VERSION" != "$PACKAGE_VERSION" ]; then
|
||||
echo "::error::Tag version ($TAG_VERSION) does not match package.json version ($PACKAGE_VERSION)"
|
||||
echo "Run: ./scripts/release.sh $TAG_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify release metadata stays in sync
|
||||
run: node tests/plugin-manifest.test.js
|
||||
|
||||
- name: Generate release highlights
|
||||
id: highlights
|
||||
env:
|
||||
@@ -74,7 +77,7 @@ jobs:
|
||||
EOF
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
with:
|
||||
body_path: release_body.md
|
||||
generate_release_notes: true
|
||||
|
||||
17
.github/workflows/reusable-release.yml
vendored
17
.github/workflows/reusable-release.yml
vendored
@@ -47,6 +47,21 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify package version matches tag
|
||||
env:
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
TAG_VERSION="${INPUT_TAG#v}"
|
||||
PACKAGE_VERSION=$(node -p "require('./package.json').version")
|
||||
if [ "$TAG_VERSION" != "$PACKAGE_VERSION" ]; then
|
||||
echo "::error::Tag version ($TAG_VERSION) does not match package.json version ($PACKAGE_VERSION)"
|
||||
echo "Run: ./scripts/release.sh $TAG_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify release metadata stays in sync
|
||||
run: node tests/plugin-manifest.test.js
|
||||
|
||||
- name: Generate release highlights
|
||||
env:
|
||||
TAG_NAME: ${{ inputs.tag }}
|
||||
@@ -62,7 +77,7 @@ jobs:
|
||||
EOF
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
with:
|
||||
tag_name: ${{ inputs.tag }}
|
||||
body_path: release_body.md
|
||||
|
||||
18
.github/workflows/reusable-test.yml
vendored
18
.github/workflows/reusable-test.yml
vendored
@@ -35,10 +35,18 @@ jobs:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
|
||||
- name: Setup pnpm
|
||||
if: inputs.package-manager == 'pnpm'
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4
|
||||
if: inputs.package-manager == 'pnpm' && inputs.node-version != '18.x'
|
||||
uses: pnpm/action-setup@08c4be7e2e672a47d11bd04269e27e5f3e8529cb # v6.0.0
|
||||
with:
|
||||
version: latest
|
||||
# Keep an explicit pnpm major because this repo's packageManager is Yarn.
|
||||
version: 10
|
||||
|
||||
- name: Setup pnpm (via Corepack)
|
||||
if: inputs.package-manager == 'pnpm' && inputs.node-version == '18.x'
|
||||
shell: bash
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@9 --activate
|
||||
|
||||
- name: Setup Yarn (via Corepack)
|
||||
if: inputs.package-manager == 'yarn'
|
||||
@@ -70,6 +78,8 @@ jobs:
|
||||
if: inputs.package-manager == 'pnpm'
|
||||
id: pnpm-cache-dir
|
||||
shell: bash
|
||||
env:
|
||||
COREPACK_ENABLE_STRICT: '0'
|
||||
run: echo "dir=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache pnpm
|
||||
@@ -134,7 +144,7 @@ jobs:
|
||||
|
||||
- name: Upload test artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-results-${{ inputs.os }}-node${{ inputs.node-version }}-${{ inputs.package-manager }}
|
||||
path: |
|
||||
|
||||
3
.github/workflows/reusable-validate.yml
vendored
3
.github/workflows/reusable-validate.yml
vendored
@@ -42,6 +42,9 @@ jobs:
|
||||
- name: Validate install manifests
|
||||
run: node scripts/ci/validate-install-manifests.js
|
||||
|
||||
- name: Validate workflow security
|
||||
run: node scripts/ci/validate-workflow-security.js
|
||||
|
||||
- name: Validate rules
|
||||
run: node scripts/ci/validate-rules.js
|
||||
|
||||
|
||||
104
.opencode/package-lock.json
generated
104
.opencode/package-lock.json
generated
@@ -9,7 +9,7 @@
|
||||
"version": "1.10.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@opencode-ai/plugin": "^1.0.0",
|
||||
"@opencode-ai/plugin": "^1.4.3",
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.3.0"
|
||||
},
|
||||
@@ -21,22 +21,37 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/plugin": {
|
||||
"version": "1.1.53",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.1.53.tgz",
|
||||
"integrity": "sha512-9ye7Wz2kESgt02AUDaMea4hXxj6XhWwKAG8NwFhrw09Ux54bGaMJFt1eIS8QQGIMaD+Lp11X4QdyEg96etEBJw==",
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.4.3.tgz",
|
||||
"integrity": "sha512-Ob/3tVSIeuMRJBr2O23RtrnC5djRe01Lglx+TwGEmjrH9yDBJ2tftegYLnNEjRoMuzITgq9LD8168p4pzv+U/A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "1.1.53",
|
||||
"@opencode-ai/sdk": "1.4.3",
|
||||
"zod": "4.1.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.1.97",
|
||||
"@opentui/solid": ">=0.1.97"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentui/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentui/solid": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/sdk": {
|
||||
"version": "1.1.53",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.53.tgz",
|
||||
"integrity": "sha512-RUIVnPOP1CyyU32FrOOYuE7Ge51lOBuhaFp2NSX98ncApT7ffoNetmwzqrhOiJQgZB1KrbCHLYOCK6AZfacxag==",
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.4.3.tgz",
|
||||
"integrity": "sha512-X0CAVbwoGAjTY2iecpWkx2B+GAa2jSaQKYpJ+xILopeF/OGKZUN15mjqci+L7cEuwLHV5wk3x2TStUOVCa5p0A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "7.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.33",
|
||||
@@ -48,6 +63,61 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
"which": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-regex": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -69,6 +139,22 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/node-which"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz",
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
"@opencode-ai/plugin": ">=1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/plugin": "^1.0.0",
|
||||
"@opencode-ai/plugin": "^1.4.3",
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.3.0"
|
||||
},
|
||||
|
||||
@@ -456,7 +456,7 @@ export const ECCHooksPlugin: ECCHooksPluginFn = async ({
|
||||
const contextBlock = [
|
||||
"# ECC Context (preserve across compaction)",
|
||||
"",
|
||||
"## Active Plugin: Everything Claude Code v1.8.0",
|
||||
"## Active Plugin: Everything Claude Code v1.10.0",
|
||||
"- Hooks: file.edited, tool.execute.before/after, session.created/idle/deleted, shell.env, compacting, permission.ask",
|
||||
"- Tools: run-tests, check-coverage, security-audit, format-code, lint-check, git-summary, changed-files",
|
||||
"- Agents: 13 specialized (planner, architect, tdd-guide, code-reviewer, security-reviewer, build-error-resolver, e2e-runner, refactor-cleaner, doc-updater, go-reviewer, go-build-resolver, database-reviewer, python-reviewer)",
|
||||
|
||||
48
README.md
48
README.md
@@ -84,6 +84,7 @@ This repo is the raw code only. The guides explain everything.
|
||||
|
||||
### v1.10.0 — Surface Refresh, Operator Workflows, and ECC 2.0 Alpha (Apr 2026)
|
||||
|
||||
- **Dashboard GUI** — New Tkinter-based desktop application (`ecc_dashboard.py` or `npm run dashboard`) with dark/light theme toggle, font customization, and project logo in header and taskbar.
|
||||
- **Public surface synced to the live repo** — metadata, catalog counts, plugin manifests, and install-facing docs now match the actual OSS surface: 38 agents, 156 skills, and 72 legacy command shims.
|
||||
- **Operator and outbound workflow expansion** — `brand-voice`, `social-graph-ranker`, `connections-optimizer`, `customer-billing-ops`, `ecc-tools-cost-audit`, `google-workspace-ops`, `project-flow-ops`, and `workspace-surface-audit` round out the operator lane.
|
||||
- **Media and launch tooling** — `manim-video`, `remotion-video-creation`, and upgraded social publishing surfaces make technical explainers and launch content part of the same system.
|
||||
@@ -179,7 +180,7 @@ Get up and running in under 2 minutes:
|
||||
### Step 2: Install Rules (Required)
|
||||
|
||||
> 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.
|
||||
|
||||
```bash
|
||||
@@ -240,6 +241,23 @@ For manual install instructions see the README in the `rules/` folder. When copy
|
||||
|
||||
**That's it!** You now have access to 47 agents, 181 skills, and 79 legacy command shims.
|
||||
|
||||
### Dashboard GUI
|
||||
|
||||
Launch the desktop dashboard to visually explore ECC components:
|
||||
|
||||
```bash
|
||||
npm run dashboard
|
||||
# or
|
||||
python3 ./ecc_dashboard.py
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Tabbed interface: Agents, Skills, Commands, Rules, Settings
|
||||
- Dark/Light theme toggle
|
||||
- Font customization (family & size)
|
||||
- Project logo in header and taskbar
|
||||
- Search and filter across all components
|
||||
|
||||
### Multi-model commands require additional setup
|
||||
|
||||
> WARNING: `multi-*` commands are **not** covered by the base plugin/rules install above.
|
||||
@@ -500,6 +518,12 @@ everything-claude-code/
|
||||
|-- mcp-configs/ # MCP server configurations
|
||||
| |-- mcp-servers.json # GitHub, Supabase, Vercel, Railway, etc.
|
||||
|
|
||||
|-- ecc_dashboard.py # Desktop GUI dashboard (Tkinter)
|
||||
|
|
||||
|-- assets/ # Assets for dashboard
|
||||
| |-- images/
|
||||
| |-- ecc-logo.png
|
||||
|
|
||||
|-- marketplace.json # Self-hosted marketplace config (for /plugin marketplace add)
|
||||
```
|
||||
|
||||
@@ -703,9 +727,27 @@ mkdir -p ~/.claude/commands
|
||||
cp everything-claude-code/commands/*.md ~/.claude/commands/
|
||||
```
|
||||
|
||||
#### Add hooks to settings.json
|
||||
#### Install hooks
|
||||
|
||||
Copy the hooks from `hooks/hooks.json` to your `~/.claude/settings.json`.
|
||||
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.
|
||||
|
||||
Use the installer to install only the Claude hook runtime so command paths are rewritten correctly:
|
||||
|
||||
```bash
|
||||
# macOS / Linux
|
||||
bash ./install.sh --target claude --modules hooks-runtime
|
||||
```
|
||||
|
||||
```powershell
|
||||
# Windows PowerShell
|
||||
pwsh -File .\install.ps1 --target claude --modules hooks-runtime
|
||||
```
|
||||
|
||||
That writes resolved hooks to `~/.claude/hooks/hooks.json` and leaves any existing `~/.claude/settings.json` untouched.
|
||||
|
||||
If you installed ECC via `/plugin install`, do not copy those hooks into `settings.json`. Claude Code v2.1+ already auto-loads plugin `hooks/hooks.json`, and duplicating them in `settings.json` causes duplicate execution and `${CLAUDE_PLUGIN_ROOT}` resolution failures.
|
||||
|
||||
Windows note: the Claude config directory is `%USERPROFILE%\\.claude`, not `~/claude`.
|
||||
|
||||
#### Configure MCPs
|
||||
|
||||
|
||||
@@ -78,6 +78,17 @@
|
||||
|
||||
---
|
||||
|
||||
## 最新动态
|
||||
|
||||
### v1.10.0 — 表面同步、运营工作流与 ECC 2.0 Alpha(2026年4月)
|
||||
|
||||
- **公共表面已与真实仓库同步** —— 元数据、目录数量、插件清单以及安装文档现在都与实际开源表面保持一致。
|
||||
- **运营与外向型工作流扩展** —— `brand-voice`、`social-graph-ranker`、`customer-billing-ops`、`google-workspace-ops` 等运营型 skill 已纳入同一系统。
|
||||
- **媒体与发布工具补齐** —— `manim-video`、`remotion-video-creation` 以及社媒发布能力让技术讲解和发布流程直接在同一仓库内完成。
|
||||
- **框架与产品表面继续扩展** —— `nestjs-patterns`、更完整的 Codex/OpenCode 安装表面,以及跨 harness 打包改进,让仓库不再局限于 Claude Code。
|
||||
- **ECC 2.0 alpha 已进入仓库** —— `ecc2/` 下的 Rust 控制层现已可在本地构建,并提供 `dashboard`、`start`、`sessions`、`status`、`stop`、`resume` 与 `daemon` 命令。
|
||||
- **生态加固持续推进** —— AgentShield、ECC Tools 成本控制、计费门户工作与网站刷新仍围绕核心插件持续交付。
|
||||
|
||||
## 快速开始
|
||||
|
||||
在 2 分钟内快速上手:
|
||||
@@ -615,7 +626,9 @@ cp everything-claude-code/commands/*.md ~/.claude/commands/
|
||||
```
|
||||
|
||||
#### 将钩子配置添加到 settings.json
|
||||
将 `hooks/hooks.json` 中的钩子配置复制到你的 `~/.claude/settings.json` 文件中。
|
||||
仅适用于手动安装:如果你没有通过 Claude 插件方式安装 ECC,可以将 `hooks/hooks.json` 中的钩子配置复制到你的 `~/.claude/settings.json` 文件中。
|
||||
|
||||
如果你是通过 `/plugin install` 安装 ECC,请不要再把这些钩子复制到 `settings.json`。Claude Code v2.1+ 会自动加载插件中的 `hooks/hooks.json`,重复注册会导致重复执行以及 `${CLAUDE_PLUGIN_ROOT}` 无法解析。
|
||||
|
||||
#### 配置 MCP 服务
|
||||
从 `mcp-configs/mcp-servers.json` 中复制需要的 MCP 服务定义,粘贴到官方 Claude Code 配置文件 `~/.claude/settings.json` 中;
|
||||
|
||||
139
agents/a11y-architect.md
Normal file
139
agents/a11y-architect.md
Normal file
@@ -0,0 +1,139 @@
|
||||
---
|
||||
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.
|
||||
tools: ["Read", "Write", "Edit", "Bash", "Grep", "Glob"]
|
||||
---
|
||||
|
||||
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.
|
||||
|
||||
## Your Role
|
||||
|
||||
- **Architecting Inclusivity**: Design UI systems that natively support assistive technologies (Screen Readers, Voice Control, Switch Access).
|
||||
- **WCAG 2.2 Enforcement**: Apply the latest success criteria, focusing on new standards like Focus Appearance, Target Size, and Redundant Entry.
|
||||
- **Platform Strategy**: Bridge the gap between Web standards (WAI-ARIA) and Native frameworks (SwiftUI/Jetpack Compose).
|
||||
- **Technical Specifications**: Provide developers with precise attributes (roles, labels, hints, and traits) required for compliance.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Contextual Discovery
|
||||
|
||||
- Determine if the target is **Web**, **iOS**, or **Android**.
|
||||
- Analyze the user interaction (e.g., Is this a simple button or a complex data grid?).
|
||||
- Identify potential accessibility "blockers" (e.g., color-only indicators, missing focus containment in modals).
|
||||
|
||||
### Step 2: Strategic Implementation
|
||||
|
||||
- **Apply the Accessibility Skill**: Invoke specific logic to generate semantic code.
|
||||
- **Define Focus Flow**: Map out how a keyboard or screen reader user will move through the interface.
|
||||
- **Optimize Touch/Pointer**: Ensure all interactive elements meet the minimum **24x24 pixel** spacing or **44x44 pixel** target size requirements.
|
||||
|
||||
### Step 3: Validation & Documentation
|
||||
|
||||
- Review the output against the WCAG 2.2 Level AA checklist.
|
||||
- Provide a brief "Implementation Note" explaining _why_ certain attributes (like `aria-live` or `accessibilityHint`) were used.
|
||||
|
||||
## Output Format
|
||||
|
||||
For every component or page request, provide:
|
||||
|
||||
1. **The Code**: Semantic HTML/ARIA or Native code.
|
||||
2. **The Accessibility Tree**: A description of what a screen reader will announce.
|
||||
3. **Compliance Mapping**: A list of specific WCAG 2.2 criteria addressed.
|
||||
|
||||
## Examples
|
||||
|
||||
### Example: Accessible Search Component
|
||||
|
||||
**Input**: "Create a search bar with a submit icon."
|
||||
**Action**: Ensuring the icon-only button has a visible label and the input is correctly labeled.
|
||||
**Output**:
|
||||
|
||||
```html
|
||||
<form role="search">
|
||||
<label for="site-search" class="sr-only">Search the site</label>
|
||||
<input type="search" id="site-search" name="q" />
|
||||
<button type="submit" aria-label="Search">
|
||||
<svg aria-hidden="true">...</svg>
|
||||
</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
## WCAG 2.2 Core Compliance Checklist
|
||||
|
||||
### 1. Perceivable (Information must be presentable)
|
||||
|
||||
- [ ] **Text Alternatives**: All non-text content has a text alternative (Alt text or labels).
|
||||
- [ ] **Contrast**: Text meets 4.5:1; UI components/graphics meet 3:1 contrast ratios.
|
||||
- [ ] **Adaptable**: Content reflows and remains functional when resized up to 400%.
|
||||
|
||||
### 2. Operable (Interface components must be usable)
|
||||
|
||||
- [ ] **Keyboard Accessible**: Every interactive element is reachable via keyboard/switch control.
|
||||
- [ ] **Navigable**: Focus order is logical, and focus indicators are high-contrast (SC 2.4.11).
|
||||
- [ ] **Pointer Gestures**: Single-pointer alternatives exist for all dragging or multipoint gestures.
|
||||
- [ ] **Target Size**: Interactive elements are at least 24x24 CSS pixels (SC 2.5.8).
|
||||
|
||||
### 3. Understandable (Information must be clear)
|
||||
|
||||
- [ ] **Predictable**: Navigation and identification of elements are consistent across the app.
|
||||
- [ ] **Input Assistance**: Forms provide clear error identification and suggestions for fix.
|
||||
- [ ] **Redundant Entry**: Avoid asking for the same info twice in a single process (SC 3.3.7).
|
||||
|
||||
### 4. Robust (Content must be compatible)
|
||||
|
||||
- [ ] **Compatibility**: Maximize compatibility with assistive tech using valid Name, Role, and Value.
|
||||
- [ ] **Status Messages**: Screen readers are notified of dynamic changes via ARIA live regions.
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Issue | Why it fails |
|
||||
| :------------------------- | :------------------------------------------------------------------------------------------------- |
|
||||
| **"Click Here" Links** | Non-descriptive; screen reader users navigating by links won't know the destination. |
|
||||
| **Fixed-Sized Containers** | Prevents content reflow and breaks the layout at higher zoom levels. |
|
||||
| **Keyboard Traps** | Prevents users from navigating the rest of the page once they enter a component. |
|
||||
| **Auto-Playing Media** | Distracting for users with cognitive disabilities; interferes with screen reader audio. |
|
||||
| **Empty Buttons** | Icon-only buttons without an `aria-label` or `accessibilityLabel` are invisible to screen readers. |
|
||||
|
||||
## Accessibility Decision Record Template
|
||||
|
||||
For major UI decisions, use this format:
|
||||
|
||||
````markdown
|
||||
# ADR-ACC-[000]: [Title of the Accessibility Decision]
|
||||
|
||||
## Status
|
||||
|
||||
Proposed | **Accepted** | Deprecated | Superseded by [ADR-XXX]
|
||||
|
||||
## Context
|
||||
|
||||
_Describe the UI component or workflow being addressed._
|
||||
|
||||
- **Platform**: [Web | iOS | Android | Cross-platform]
|
||||
- **WCAG 2.2 Success Criterion**: [e.g., 2.5.8 Target Size (Minimum)]
|
||||
- **Problem**: What is the current accessibility barrier? (e.g., "The 'Close' button in the modal is too small for users with motor impairments.")
|
||||
|
||||
## Decision
|
||||
|
||||
_Detail the specific implementation choice._
|
||||
"We will implement a touch target of at least 44x44 points for all mobile navigation elements and 24x24 CSS pixels for web, ensuring a minimum 4px spacing between adjacent targets."
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Code/Spec
|
||||
|
||||
```[language]
|
||||
// Example: SwiftUI
|
||||
Button(action: close) {
|
||||
Image(systemName: "xmark")
|
||||
.frame(width: 44, height: 44) // Standardizing hit area
|
||||
}
|
||||
.accessibilityLabel("Close modal")
|
||||
```
|
||||
````
|
||||
|
||||
## Reference
|
||||
|
||||
- See skill `accessibility` to transform raw UI requirements into platform-specific accessible code (WAI-ARIA, SwiftUI, or Jetpack Compose) based on WCAG 2.2 criteria.
|
||||
BIN
assets/images/ecc-logo.png
Normal file
BIN
assets/images/ecc-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
@@ -29,8 +29,9 @@ Use `/sessions info` when you need operator-surface context for a swarm: branch,
|
||||
**Script:**
|
||||
```bash
|
||||
node -e "
|
||||
const sm = require((()=>{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;try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q)))return c}}catch(x){}return d})()+'/scripts/lib/session-manager');
|
||||
const aa = require((()=>{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;try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q)))return c}}catch(x){}return d})()+'/scripts/lib/session-aliases');
|
||||
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 sm = require(_r + '/scripts/lib/session-manager');
|
||||
const aa = require(_r + '/scripts/lib/session-aliases');
|
||||
const path = require('path');
|
||||
|
||||
const result = sm.getAllSessions({ limit: 20 });
|
||||
@@ -70,8 +71,9 @@ Load and display a session's content (by ID or alias).
|
||||
**Script:**
|
||||
```bash
|
||||
node -e "
|
||||
const sm = require((()=>{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;try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q)))return c}}catch(x){}return d})()+'/scripts/lib/session-manager');
|
||||
const aa = require((()=>{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;try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q)))return c}}catch(x){}return d})()+'/scripts/lib/session-aliases');
|
||||
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 sm = require(_r + '/scripts/lib/session-manager');
|
||||
const aa = require(_r + '/scripts/lib/session-aliases');
|
||||
const id = process.argv[1];
|
||||
|
||||
// First try to resolve as alias
|
||||
@@ -143,8 +145,9 @@ Create a memorable alias for a session.
|
||||
**Script:**
|
||||
```bash
|
||||
node -e "
|
||||
const sm = require((()=>{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;try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q)))return c}}catch(x){}return d})()+'/scripts/lib/session-manager');
|
||||
const aa = require((()=>{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;try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q)))return c}}catch(x){}return d})()+'/scripts/lib/session-aliases');
|
||||
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 sm = require(_r + '/scripts/lib/session-manager');
|
||||
const aa = require(_r + '/scripts/lib/session-aliases');
|
||||
|
||||
const sessionId = process.argv[1];
|
||||
const aliasName = process.argv[2];
|
||||
@@ -183,7 +186,8 @@ Delete an existing alias.
|
||||
**Script:**
|
||||
```bash
|
||||
node -e "
|
||||
const aa = require((()=>{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;try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q)))return c}}catch(x){}return d})()+'/scripts/lib/session-aliases');
|
||||
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 aa = require(_r + '/scripts/lib/session-aliases');
|
||||
|
||||
const aliasName = process.argv[1];
|
||||
if (!aliasName) {
|
||||
@@ -212,8 +216,9 @@ Show detailed information about a session.
|
||||
**Script:**
|
||||
```bash
|
||||
node -e "
|
||||
const sm = require((()=>{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;try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q)))return c}}catch(x){}return d})()+'/scripts/lib/session-manager');
|
||||
const aa = require((()=>{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;try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q)))return c}}catch(x){}return d})()+'/scripts/lib/session-aliases');
|
||||
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 sm = require(_r + '/scripts/lib/session-manager');
|
||||
const aa = require(_r + '/scripts/lib/session-aliases');
|
||||
|
||||
const id = process.argv[1];
|
||||
const resolved = aa.resolveAlias(id);
|
||||
@@ -262,7 +267,8 @@ Show all session aliases.
|
||||
**Script:**
|
||||
```bash
|
||||
node -e "
|
||||
const aa = require((()=>{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;try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q)))return c}}catch(x){}return d})()+'/scripts/lib/session-aliases');
|
||||
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 aa = require(_r + '/scripts/lib/session-aliases');
|
||||
|
||||
const aliases = aa.listAliases();
|
||||
console.log('Session Aliases (' + aliases.length + '):');
|
||||
|
||||
@@ -13,21 +13,21 @@ Shows a comprehensive health dashboard for all skills in the portfolio with succ
|
||||
Run the skill health CLI in dashboard mode:
|
||||
|
||||
```bash
|
||||
ECC_ROOT="${CLAUDE_PLUGIN_ROOT:-$(node -e "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))){try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q))){d=c;break}}}catch(x){}}console.log(d)")}"
|
||||
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/skills-health.js" --dashboard
|
||||
```
|
||||
|
||||
For a specific panel only:
|
||||
|
||||
```bash
|
||||
ECC_ROOT="${CLAUDE_PLUGIN_ROOT:-$(node -e "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))){try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q))){d=c;break}}}catch(x){}}console.log(d)")}"
|
||||
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/skills-health.js" --dashboard --panel failures
|
||||
```
|
||||
|
||||
For machine-readable output:
|
||||
|
||||
```bash
|
||||
ECC_ROOT="${CLAUDE_PLUGIN_ROOT:-$(node -e "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))){try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q))){d=c;break}}}catch(x){}}console.log(d)")}"
|
||||
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/skills-health.js" --dashboard --json
|
||||
```
|
||||
|
||||
|
||||
@@ -497,7 +497,9 @@ cp -r everything-claude-code/skills/* ~/.claude/skills/
|
||||
|
||||
#### settings.json にフックを追加
|
||||
|
||||
`hooks/hooks.json` のフックを `~/.claude/settings.json` にコピーします。
|
||||
手動インストール時のみ、`hooks/hooks.json` のフックを `~/.claude/settings.json` にコピーします。
|
||||
|
||||
`/plugin install` で ECC を導入した場合は、これらのフックを `settings.json` にコピーしないでください。Claude Code v2.1+ はプラグインの `hooks/hooks.json` を自動読み込みするため、二重登録すると重複実行や `${CLAUDE_PLUGIN_ROOT}` の解決失敗が発生します。
|
||||
|
||||
#### MCP を設定
|
||||
|
||||
|
||||
@@ -80,6 +80,15 @@ Este repositório contém apenas o código. Os guias explicam tudo.
|
||||
|
||||
## O Que Há de Novo
|
||||
|
||||
### v1.10.0 — Sincronização de Superfície, Fluxos Operacionais e ECC 2.0 Alpha (Abr 2026)
|
||||
|
||||
- **Superfície pública sincronizada com o repositório real** — metadados, contagens de catálogo, manifests de plugin e documentação de instalação agora refletem a superfície OSS que realmente é entregue.
|
||||
- **Expansão dos fluxos operacionais e externos** — `brand-voice`, `social-graph-ranker`, `customer-billing-ops`, `google-workspace-ops` e skills relacionadas fortalecem a trilha operacional dentro do mesmo sistema.
|
||||
- **Ferramentas de mídia e lançamento** — `manim-video`, `remotion-video-creation` e os fluxos de publicação social colocam explicadores técnicos e lançamento no mesmo repositório.
|
||||
- **Crescimento de framework e superfície de produto** — `nestjs-patterns`, superfícies de instalação mais ricas para Codex/OpenCode e melhorias de empacotamento cross-harness ampliam o uso além do Claude Code.
|
||||
- **ECC 2.0 alpha já está no repositório** — o plano de controle em Rust dentro de `ecc2/` já compila localmente e expõe `dashboard`, `start`, `sessions`, `status`, `stop`, `resume` e `daemon`.
|
||||
- **Fortalecimento do ecossistema** — AgentShield, controles de custo do ECC Tools, trabalho no portal de billing e a renovação do site continuam sendo entregues ao redor do plugin principal.
|
||||
|
||||
### v1.9.0 — Instalação Seletiva e Expansão de Idiomas (Mar 2026)
|
||||
|
||||
- **Arquitetura de instalação seletiva** — Pipeline de instalação baseado em manifesto com `install-plan.js` e `install-apply.js` para instalação de componentes direcionada. O state store rastreia o que está instalado e habilita atualizações incrementais.
|
||||
|
||||
@@ -79,6 +79,15 @@ Bu repository yalnızca ham kodu içerir. Rehberler her şeyi açıklıyor.
|
||||
|
||||
## Yenilikler
|
||||
|
||||
### v1.10.0 — Surface Sync, Operatör İş Akışları ve ECC 2.0 Alpha (Nis 2026)
|
||||
|
||||
- **Public surface canlı repo ile senkronlandı** — metadata, katalog sayıları, plugin manifest'leri ve kurulum odaklı dokümanlar artık gerçek OSS yüzeyiyle eşleşiyor.
|
||||
- **Operatör ve dışa dönük iş akışları büyüdü** — `brand-voice`, `social-graph-ranker`, `customer-billing-ops`, `google-workspace-ops` ve ilgili operatör skill'leri aynı sistem içinde tamamlandı.
|
||||
- **Medya ve lansman araçları** — `manim-video`, `remotion-video-creation` ve sosyal yayın yüzeyleri teknik anlatım ve duyuru akışlarını aynı repo içine taşıdı.
|
||||
- **Framework ve ürün yüzeyi genişledi** — `nestjs-patterns`, daha zengin Codex/OpenCode kurulum yüzeyleri ve çapraz harness paketleme iyileştirmeleri repo'yu Claude Code dışına da taşıdı.
|
||||
- **ECC 2.0 alpha repoda** — `ecc2/` altındaki Rust kontrol katmanı artık yerelde derleniyor ve `dashboard`, `start`, `sessions`, `status`, `stop`, `resume` ve `daemon` komutlarını sunuyor.
|
||||
- **Ekosistem sağlamlaştırma** — AgentShield, ECC Tools maliyet kontrolleri, billing portal işleri ve web yüzeyi çekirdek plugin etrafında birlikte gelişmeye devam ediyor.
|
||||
|
||||
### v1.9.0 — Seçici Kurulum & Dil Genişlemesi (Mar 2026)
|
||||
|
||||
- **Seçici kurulum mimarisi** — `install-plan.js` ve `install-apply.js` ile manifest-tabanlı kurulum pipeline'ı, hedefli component kurulumu için. State store neyin kurulu olduğunu takip eder ve artımlı güncellemelere olanak sağlar.
|
||||
|
||||
@@ -318,7 +318,9 @@ cp -r everything-claude-code/skills/* ~/.claude/skills/
|
||||
|
||||
#### 將鉤子新增到 settings.json
|
||||
|
||||
將 `hooks/hooks.json` 中的鉤子複製到您的 `~/.claude/settings.json`。
|
||||
僅在手動安裝時,才將 `hooks/hooks.json` 中的鉤子複製到您的 `~/.claude/settings.json`。
|
||||
|
||||
如果您是透過 `/plugin install` 安裝 ECC,請不要再把這些鉤子複製到 `settings.json`。Claude Code v2.1+ 會自動載入外掛中的 `hooks/hooks.json`,重複註冊會導致重複執行以及 `${CLAUDE_PLUGIN_ROOT}` 無法解析。
|
||||
|
||||
#### 設定 MCP
|
||||
|
||||
|
||||
10
ecc2/Cargo.lock
generated
10
ecc2/Cargo.lock
generated
@@ -1286,6 +1286,15 @@ version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-src"
|
||||
version = "300.6.0+3.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.112"
|
||||
@@ -1294,6 +1303,7 @@ checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"openssl-src",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
@@ -7,6 +7,10 @@ license = "MIT"
|
||||
authors = ["Affaan Mustafa <me@affaanmustafa.com>"]
|
||||
repository = "https://github.com/affaan-m/everything-claude-code"
|
||||
|
||||
[features]
|
||||
default = ["vendored-openssl"]
|
||||
vendored-openssl = ["git2/vendored-openssl"]
|
||||
|
||||
[dependencies]
|
||||
# TUI
|
||||
ratatui = { version = "0.30", features = ["crossterm_0_28"] }
|
||||
@@ -19,7 +23,7 @@ tokio = { version = "1", features = ["full"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
|
||||
# Git integration
|
||||
git2 = "0.20"
|
||||
git2 = { version = "0.20", features = ["ssh"] }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -4,6 +4,7 @@ use cron::Schedule as CronSchedule;
|
||||
use serde::Serialize;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::fmt;
|
||||
use std::fs::OpenOptions;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::str::FromStr;
|
||||
@@ -2983,7 +2984,28 @@ async fn spawn_session_runner_for_program(
|
||||
working_dir: &Path,
|
||||
current_exe: &Path,
|
||||
) -> Result<()> {
|
||||
let child = Command::new(current_exe)
|
||||
let stderr_log_path = background_runner_stderr_log_path(working_dir, session_id);
|
||||
if let Some(parent) = stderr_log_path.parent() {
|
||||
std::fs::create_dir_all(parent).with_context(|| {
|
||||
format!(
|
||||
"Failed to create ECC runner log directory {}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let stderr_log = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&stderr_log_path)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Failed to open ECC runner stderr log {}",
|
||||
stderr_log_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut command = Command::new(current_exe);
|
||||
command
|
||||
.arg("run-session")
|
||||
.arg("--session-id")
|
||||
.arg(session_id)
|
||||
@@ -2995,7 +3017,10 @@ async fn spawn_session_runner_for_program(
|
||||
.arg(working_dir)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.stderr(Stdio::from(stderr_log));
|
||||
configure_background_runner_command(&mut command);
|
||||
|
||||
let child = command
|
||||
.spawn()
|
||||
.with_context(|| format!("Failed to spawn ECC runner from {}", current_exe.display()))?;
|
||||
|
||||
@@ -3005,6 +3030,46 @@ async fn spawn_session_runner_for_program(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn background_runner_stderr_log_path(working_dir: &Path, session_id: &str) -> PathBuf {
|
||||
working_dir
|
||||
.join(".claude")
|
||||
.join("ecc2")
|
||||
.join("logs")
|
||||
.join(format!("{session_id}.runner-stderr.log"))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn detached_creation_flags() -> u32 {
|
||||
const DETACHED_PROCESS: u32 = 0x0000_0008;
|
||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
|
||||
DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
|
||||
}
|
||||
|
||||
fn configure_background_runner_command(command: &mut Command) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
// Detach the runner from the caller's shell/session so it keeps
|
||||
// processing a live harness session after `ecc-tui start` returns.
|
||||
unsafe {
|
||||
command.as_std_mut().pre_exec(|| {
|
||||
if libc::setsid() == -1 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
command.as_std_mut().creation_flags(detached_creation_flags());
|
||||
}
|
||||
}
|
||||
|
||||
fn build_agent_command(
|
||||
cfg: &Config,
|
||||
agent_type: &str,
|
||||
@@ -5032,6 +5097,22 @@ mod tests {
|
||||
anyhow::bail!("timed out waiting for {}", path.display());
|
||||
}
|
||||
|
||||
fn wait_for_text(path: &Path, needle: &str) -> Result<String> {
|
||||
for _ in 0..200 {
|
||||
if path.exists() {
|
||||
let content = fs::read_to_string(path)
|
||||
.with_context(|| format!("failed to read {}", path.display()))?;
|
||||
if content.contains(needle) {
|
||||
return Ok(content);
|
||||
}
|
||||
}
|
||||
|
||||
thread::sleep(StdDuration::from_millis(20));
|
||||
}
|
||||
|
||||
anyhow::bail!("timed out waiting for {}", path.display());
|
||||
}
|
||||
|
||||
fn command_env_map(command: &Command) -> BTreeMap<String, String> {
|
||||
command
|
||||
.as_std()
|
||||
@@ -5047,6 +5128,63 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn background_runner_command_starts_new_session() -> Result<()> {
|
||||
let tempdir = TestDir::new("manager-detached-runner")?;
|
||||
let script_path = tempdir.path().join("detached-runner.py");
|
||||
let log_path = tempdir.path().join("detached-runner.log");
|
||||
let script = format!(
|
||||
"#!/usr/bin/env python3\nimport os\nimport pathlib\nimport time\n\npath = pathlib.Path(r\"{}\")\npath.write_text(f\"pid={{os.getpid()}} sid={{os.getsid(0)}}\", encoding=\"utf-8\")\ntime.sleep(30)\n",
|
||||
log_path.display()
|
||||
);
|
||||
fs::write(&script_path, script)?;
|
||||
let mut permissions = fs::metadata(&script_path)?.permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&script_path, permissions)?;
|
||||
|
||||
let mut command = Command::new(&script_path);
|
||||
command
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
configure_background_runner_command(&mut command);
|
||||
|
||||
let mut child = command.spawn()?;
|
||||
let child_pid = child.id().context("detached child pid")? as i32;
|
||||
let content = wait_for_text(&log_path, "sid=")?;
|
||||
let sid = content
|
||||
.split_whitespace()
|
||||
.find_map(|part| part.strip_prefix("sid="))
|
||||
.context("session id should be logged")?
|
||||
.parse::<i32>()
|
||||
.context("session id should parse")?;
|
||||
let parent_sid = unsafe { libc::getsid(0) };
|
||||
|
||||
assert_eq!(sid, child_pid);
|
||||
assert_ne!(sid, parent_sid);
|
||||
|
||||
let _ = child.kill().await;
|
||||
let _ = child.wait().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_runner_stderr_log_path_is_session_scoped() {
|
||||
let path =
|
||||
background_runner_stderr_log_path(Path::new("/tmp/ecc-repo"), "session-123");
|
||||
assert_eq!(
|
||||
path,
|
||||
PathBuf::from("/tmp/ecc-repo/.claude/ecc2/logs/session-123.runner-stderr.log")
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn detached_creation_flags_include_detach_and_process_group() {
|
||||
assert_eq!(detached_creation_flags(), 0x0000_0008 | 0x0000_0200);
|
||||
}
|
||||
|
||||
fn write_package_manager_project_files(
|
||||
repo_root: &Path,
|
||||
package_manager_field: Option<&str>,
|
||||
|
||||
914
ecc_dashboard.py
Normal file
914
ecc_dashboard.py
Normal file
@@ -0,0 +1,914 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ECC Dashboard - Everything Claude Code GUI
|
||||
Cross-platform TkInter application for managing ECC components
|
||||
"""
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, scrolledtext, messagebox
|
||||
import os
|
||||
import json
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
# ============================================================================
|
||||
# DATA LOADERS - Load ECC data from the project
|
||||
# ============================================================================
|
||||
|
||||
def get_project_path() -> str:
|
||||
"""Get the ECC project path - assumes this script is run from the project dir"""
|
||||
return os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
def load_agents(project_path: str) -> List[Dict]:
|
||||
"""Load agents from AGENTS.md"""
|
||||
agents_file = os.path.join(project_path, "AGENTS.md")
|
||||
agents = []
|
||||
|
||||
if os.path.exists(agents_file):
|
||||
with open(agents_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Parse agent table from AGENTS.md
|
||||
lines = content.split('\n')
|
||||
in_table = False
|
||||
for line in lines:
|
||||
if '| Agent | Purpose | When to Use |' in line:
|
||||
in_table = True
|
||||
continue
|
||||
if in_table and line.startswith('|'):
|
||||
parts = [p.strip() for p in line.split('|')]
|
||||
if len(parts) >= 4 and parts[1] and parts[1] != 'Agent':
|
||||
agents.append({
|
||||
'name': parts[1],
|
||||
'purpose': parts[2],
|
||||
'when_to_use': parts[3]
|
||||
})
|
||||
|
||||
# Fallback default agents if file not found
|
||||
if not agents:
|
||||
agents = [
|
||||
{'name': 'planner', 'purpose': 'Implementation planning', 'when_to_use': 'Complex features, refactoring'},
|
||||
{'name': 'architect', 'purpose': 'System design and scalability', 'when_to_use': 'Architectural decisions'},
|
||||
{'name': 'tdd-guide', 'purpose': 'Test-driven development', 'when_to_use': 'New features, bug fixes'},
|
||||
{'name': 'code-reviewer', 'purpose': 'Code quality and maintainability', 'when_to_use': 'After writing/modifying code'},
|
||||
{'name': 'security-reviewer', 'purpose': 'Vulnerability detection', 'when_to_use': 'Before commits, sensitive code'},
|
||||
{'name': 'build-error-resolver', 'purpose': 'Fix build/type errors', 'when_to_use': 'When build fails'},
|
||||
{'name': 'e2e-runner', 'purpose': 'End-to-end Playwright testing', 'when_to_use': 'Critical user flows'},
|
||||
{'name': 'refactor-cleaner', 'purpose': 'Dead code cleanup', 'when_to_use': 'Code maintenance'},
|
||||
{'name': 'doc-updater', 'purpose': 'Documentation and codemaps', 'when_to_use': 'Updating docs'},
|
||||
{'name': 'go-reviewer', 'purpose': 'Go code review', 'when_to_use': 'Go projects'},
|
||||
{'name': 'python-reviewer', 'purpose': 'Python code review', 'when_to_use': 'Python projects'},
|
||||
{'name': 'typescript-reviewer', 'purpose': 'TypeScript/JavaScript code review', 'when_to_use': 'TypeScript projects'},
|
||||
{'name': 'rust-reviewer', 'purpose': 'Rust code review', 'when_to_use': 'Rust projects'},
|
||||
{'name': 'java-reviewer', 'purpose': 'Java and Spring Boot code review', 'when_to_use': 'Java projects'},
|
||||
{'name': 'kotlin-reviewer', 'purpose': 'Kotlin code review', 'when_to_use': 'Kotlin projects'},
|
||||
{'name': 'cpp-reviewer', 'purpose': 'C/C++ code review', 'when_to_use': 'C/C++ projects'},
|
||||
{'name': 'database-reviewer', 'purpose': 'PostgreSQL/Supabase specialist', 'when_to_use': 'Database work'},
|
||||
{'name': 'loop-operator', 'purpose': 'Autonomous loop execution', 'when_to_use': 'Run loops safely'},
|
||||
{'name': 'harness-optimizer', 'purpose': 'Harness config tuning', 'when_to_use': 'Reliability, cost, throughput'},
|
||||
]
|
||||
|
||||
return agents
|
||||
|
||||
def load_skills(project_path: str) -> List[Dict]:
|
||||
"""Load skills from skills directory"""
|
||||
skills_dir = os.path.join(project_path, "skills")
|
||||
skills = []
|
||||
|
||||
if os.path.exists(skills_dir):
|
||||
for item in os.listdir(skills_dir):
|
||||
skill_path = os.path.join(skills_dir, item)
|
||||
if os.path.isdir(skill_path):
|
||||
skill_file = os.path.join(skill_path, "SKILL.md")
|
||||
description = item.replace('-', ' ').title()
|
||||
|
||||
if os.path.exists(skill_file):
|
||||
try:
|
||||
with open(skill_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
# Extract description from first lines
|
||||
lines = content.split('\n')
|
||||
for line in lines:
|
||||
if line.strip() and not line.startswith('#'):
|
||||
description = line.strip()[:100]
|
||||
break
|
||||
if line.startswith('# '):
|
||||
description = line[2:].strip()[:100]
|
||||
break
|
||||
except:
|
||||
pass
|
||||
|
||||
# Determine category
|
||||
category = "General"
|
||||
item_lower = item.lower()
|
||||
if 'python' in item_lower or 'django' in item_lower:
|
||||
category = "Python"
|
||||
elif 'golang' in item_lower or 'go-' in item_lower:
|
||||
category = "Go"
|
||||
elif 'frontend' in item_lower or 'react' in item_lower:
|
||||
category = "Frontend"
|
||||
elif 'backend' in item_lower or 'api' in item_lower:
|
||||
category = "Backend"
|
||||
elif 'security' in item_lower:
|
||||
category = "Security"
|
||||
elif 'testing' in item_lower or 'tdd' in item_lower:
|
||||
category = "Testing"
|
||||
elif 'docker' in item_lower or 'deployment' in item_lower:
|
||||
category = "DevOps"
|
||||
elif 'swift' in item_lower or 'ios' in item_lower:
|
||||
category = "iOS"
|
||||
elif 'java' in item_lower or 'spring' in item_lower:
|
||||
category = "Java"
|
||||
elif 'rust' in item_lower:
|
||||
category = "Rust"
|
||||
|
||||
skills.append({
|
||||
'name': item,
|
||||
'description': description,
|
||||
'category': category,
|
||||
'path': skill_path
|
||||
})
|
||||
|
||||
# Fallback if directory doesn't exist
|
||||
if not skills:
|
||||
skills = [
|
||||
{'name': 'tdd-workflow', 'description': 'Test-driven development workflow', 'category': 'Testing'},
|
||||
{'name': 'coding-standards', 'description': 'Baseline coding conventions', 'category': 'General'},
|
||||
{'name': 'security-review', 'description': 'Security checklist and patterns', 'category': 'Security'},
|
||||
{'name': 'frontend-patterns', 'description': 'React and Next.js patterns', 'category': 'Frontend'},
|
||||
{'name': 'backend-patterns', 'description': 'API and database patterns', 'category': 'Backend'},
|
||||
{'name': 'api-design', 'description': 'REST API design patterns', 'category': 'Backend'},
|
||||
{'name': 'docker-patterns', 'description': 'Docker and container patterns', 'category': 'DevOps'},
|
||||
{'name': 'e2e-testing', 'description': 'Playwright E2E testing patterns', 'category': 'Testing'},
|
||||
{'name': 'verification-loop', 'description': 'Build, test, lint verification', 'category': 'General'},
|
||||
{'name': 'python-patterns', 'description': 'Python idioms and best practices', 'category': 'Python'},
|
||||
{'name': 'golang-patterns', 'description': 'Go idioms and best practices', 'category': 'Go'},
|
||||
{'name': 'django-patterns', 'description': 'Django patterns and best practices', 'category': 'Python'},
|
||||
{'name': 'springboot-patterns', 'description': 'Java Spring Boot patterns', 'category': 'Java'},
|
||||
{'name': 'laravel-patterns', 'description': 'Laravel architecture patterns', 'category': 'PHP'},
|
||||
]
|
||||
|
||||
return skills
|
||||
|
||||
def load_commands(project_path: str) -> List[Dict]:
|
||||
"""Load commands from commands directory"""
|
||||
commands_dir = os.path.join(project_path, "commands")
|
||||
commands = []
|
||||
|
||||
if os.path.exists(commands_dir):
|
||||
for item in os.listdir(commands_dir):
|
||||
if item.endswith('.md'):
|
||||
cmd_name = item[:-3]
|
||||
description = ""
|
||||
|
||||
try:
|
||||
with open(os.path.join(commands_dir, item), 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
lines = content.split('\n')
|
||||
for line in lines:
|
||||
if line.startswith('# '):
|
||||
description = line[2:].strip()
|
||||
break
|
||||
except:
|
||||
pass
|
||||
|
||||
commands.append({
|
||||
'name': cmd_name,
|
||||
'description': description or cmd_name.replace('-', ' ').title()
|
||||
})
|
||||
|
||||
# Fallback commands
|
||||
if not commands:
|
||||
commands = [
|
||||
{'name': 'plan', 'description': 'Create implementation plan'},
|
||||
{'name': 'tdd', 'description': 'Test-driven development workflow'},
|
||||
{'name': 'code-review', 'description': 'Review code for quality and security'},
|
||||
{'name': 'build-fix', 'description': 'Fix build and TypeScript errors'},
|
||||
{'name': 'e2e', 'description': 'Generate and run E2E tests'},
|
||||
{'name': 'refactor-clean', 'description': 'Remove dead code'},
|
||||
{'name': 'verify', 'description': 'Run verification loop'},
|
||||
{'name': 'eval', 'description': 'Run evaluation against criteria'},
|
||||
{'name': 'security', 'description': 'Run comprehensive security review'},
|
||||
{'name': 'test-coverage', 'description': 'Analyze test coverage'},
|
||||
{'name': 'update-docs', 'description': 'Update documentation'},
|
||||
{'name': 'setup-pm', 'description': 'Configure package manager'},
|
||||
{'name': 'go-review', 'description': 'Go code review'},
|
||||
{'name': 'go-test', 'description': 'Go TDD workflow'},
|
||||
{'name': 'python-review', 'description': 'Python code review'},
|
||||
]
|
||||
|
||||
return commands
|
||||
|
||||
def load_rules(project_path: str) -> List[Dict]:
|
||||
"""Load rules from rules directory"""
|
||||
rules_dir = os.path.join(project_path, "rules")
|
||||
rules = []
|
||||
|
||||
if os.path.exists(rules_dir):
|
||||
for item in os.listdir(rules_dir):
|
||||
item_path = os.path.join(rules_dir, item)
|
||||
if os.path.isdir(item_path):
|
||||
# Common rules
|
||||
if item == "common":
|
||||
for file in os.listdir(item_path):
|
||||
if file.endswith('.md'):
|
||||
rules.append({
|
||||
'name': file[:-3],
|
||||
'language': 'Common',
|
||||
'path': os.path.join(item_path, file)
|
||||
})
|
||||
else:
|
||||
# Language-specific rules
|
||||
for file in os.listdir(item_path):
|
||||
if file.endswith('.md'):
|
||||
rules.append({
|
||||
'name': file[:-3],
|
||||
'language': item.title(),
|
||||
'path': os.path.join(item_path, file)
|
||||
})
|
||||
|
||||
# Fallback rules
|
||||
if not rules:
|
||||
rules = [
|
||||
{'name': 'coding-style', 'language': 'Common', 'path': ''},
|
||||
{'name': 'git-workflow', 'language': 'Common', 'path': ''},
|
||||
{'name': 'testing', 'language': 'Common', 'path': ''},
|
||||
{'name': 'performance', 'language': 'Common', 'path': ''},
|
||||
{'name': 'patterns', 'language': 'Common', 'path': ''},
|
||||
{'name': 'security', 'language': 'Common', 'path': ''},
|
||||
{'name': 'typescript', 'language': 'TypeScript', 'path': ''},
|
||||
{'name': 'python', 'language': 'Python', 'path': ''},
|
||||
{'name': 'golang', 'language': 'Go', 'path': ''},
|
||||
{'name': 'swift', 'language': 'Swift', 'path': ''},
|
||||
{'name': 'php', 'language': 'PHP', 'path': ''},
|
||||
]
|
||||
|
||||
return rules
|
||||
|
||||
# ============================================================================
|
||||
# MAIN APPLICATION
|
||||
# ============================================================================
|
||||
|
||||
class ECCDashboard(tk.Tk):
|
||||
"""Main ECC Dashboard Application"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self.project_path = get_project_path()
|
||||
self.title("ECC Dashboard - Everything Claude Code")
|
||||
|
||||
self.state('zoomed')
|
||||
|
||||
try:
|
||||
self.icon_image = tk.PhotoImage(file='assets/images/ecc-logo.png')
|
||||
self.iconphoto(True, self.icon_image)
|
||||
except:
|
||||
pass
|
||||
|
||||
self.minsize(800, 600)
|
||||
|
||||
# Load data
|
||||
self.agents = load_agents(self.project_path)
|
||||
self.skills = load_skills(self.project_path)
|
||||
self.commands = load_commands(self.project_path)
|
||||
self.rules = load_rules(self.project_path)
|
||||
|
||||
# Settings
|
||||
self.settings = {
|
||||
'project_path': self.project_path,
|
||||
'theme': 'light'
|
||||
}
|
||||
|
||||
# Setup UI
|
||||
self.setup_styles()
|
||||
self.create_widgets()
|
||||
|
||||
# Center window
|
||||
self.center_window()
|
||||
|
||||
def setup_styles(self):
|
||||
"""Setup ttk styles for modern look"""
|
||||
style = ttk.Style()
|
||||
style.theme_use('clam')
|
||||
|
||||
# Configure tab style
|
||||
style.configure('TNotebook', background='#f0f0f0')
|
||||
style.configure('TNotebook.Tab', padding=[10, 5], font=('Arial', 10))
|
||||
style.map('TNotebook.Tab', background=[('selected', '#ffffff')])
|
||||
|
||||
# Configure Treeview
|
||||
style.configure('Treeview', font=('Arial', 10), rowheight=25)
|
||||
style.configure('Treeview.Heading', font=('Arial', 10, 'bold'))
|
||||
|
||||
# Configure buttons
|
||||
style.configure('TButton', font=('Arial', 10), padding=5)
|
||||
|
||||
def center_window(self):
|
||||
"""Center the window on screen"""
|
||||
self.update_idletasks()
|
||||
width = self.winfo_width()
|
||||
height = self.winfo_height()
|
||||
x = (self.winfo_screenwidth() // 2) - (width // 2)
|
||||
y = (self.winfo_screenheight() // 2) - (height // 2)
|
||||
self.geometry(f'{width}x{height}+{x}+{y}')
|
||||
|
||||
def create_widgets(self):
|
||||
"""Create all UI widgets"""
|
||||
# Main container
|
||||
main_frame = ttk.Frame(self)
|
||||
main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
|
||||
|
||||
# Header
|
||||
header_frame = ttk.Frame(main_frame)
|
||||
header_frame.pack(fill=tk.X, pady=(0, 10))
|
||||
|
||||
try:
|
||||
self.logo_image = tk.PhotoImage(file='assets/images/ecc-logo.png')
|
||||
self.logo_image = self.logo_image.subsample(2, 2)
|
||||
ttk.Label(header_frame, image=self.logo_image).pack(side=tk.LEFT, padx=(0, 10))
|
||||
except:
|
||||
pass
|
||||
|
||||
self.title_label = ttk.Label(header_frame, text="ECC Dashboard", font=('Open Sans', 18, 'bold'))
|
||||
self.title_label.pack(side=tk.LEFT)
|
||||
self.version_label = ttk.Label(header_frame, text="v1.10.0", font=('Open Sans', 10), foreground='gray')
|
||||
self.version_label.pack(side=tk.LEFT, padx=(10, 0))
|
||||
|
||||
# Notebook (tabs)
|
||||
self.notebook = ttk.Notebook(main_frame)
|
||||
self.notebook.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
# Create tabs
|
||||
self.create_agents_tab()
|
||||
self.create_skills_tab()
|
||||
self.create_commands_tab()
|
||||
self.create_rules_tab()
|
||||
self.create_settings_tab()
|
||||
|
||||
# Status bar
|
||||
status_frame = ttk.Frame(main_frame)
|
||||
status_frame.pack(fill=tk.X, pady=(10, 0))
|
||||
|
||||
self.status_label = ttk.Label(status_frame,
|
||||
text=f"Ready | Agents: {len(self.agents)} | Skills: {len(self.skills)} | Commands: {len(self.commands)}",
|
||||
font=('Arial', 9), foreground='gray')
|
||||
self.status_label.pack(side=tk.LEFT)
|
||||
|
||||
# =========================================================================
|
||||
# AGENTS TAB
|
||||
# =========================================================================
|
||||
|
||||
def create_agents_tab(self):
|
||||
"""Create Agents tab"""
|
||||
frame = ttk.Frame(self.notebook)
|
||||
self.notebook.add(frame, text=f"Agents ({len(self.agents)})")
|
||||
|
||||
# Search bar
|
||||
search_frame = ttk.Frame(frame)
|
||||
search_frame.pack(fill=tk.X, padx=10, pady=10)
|
||||
|
||||
ttk.Label(search_frame, text="Search:").pack(side=tk.LEFT)
|
||||
self.agent_search = ttk.Entry(search_frame, width=30)
|
||||
self.agent_search.pack(side=tk.LEFT, padx=5)
|
||||
self.agent_search.bind('<KeyRelease>', self.filter_agents)
|
||||
|
||||
ttk.Label(search_frame, text="Count:").pack(side=tk.LEFT, padx=(20, 0))
|
||||
self.agent_count_label = ttk.Label(search_frame, text=str(len(self.agents)))
|
||||
self.agent_count_label.pack(side=tk.LEFT)
|
||||
|
||||
# Split pane: list + details
|
||||
paned = ttk.PanedWindow(frame, orient=tk.HORIZONTAL)
|
||||
paned.pack(fill=tk.BOTH, expand=True, padx=10, pady=(0, 10))
|
||||
|
||||
# Agent list
|
||||
list_frame = ttk.Frame(paned)
|
||||
paned.add(list_frame, weight=2)
|
||||
|
||||
columns = ('name', 'purpose')
|
||||
self.agent_tree = ttk.Treeview(list_frame, columns=columns, show='tree headings')
|
||||
self.agent_tree.heading('#0', text='#')
|
||||
self.agent_tree.heading('name', text='Agent Name')
|
||||
self.agent_tree.heading('purpose', text='Purpose')
|
||||
self.agent_tree.column('#0', width=40)
|
||||
self.agent_tree.column('name', width=180)
|
||||
self.agent_tree.column('purpose', width=250)
|
||||
|
||||
self.agent_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||
|
||||
# Scrollbar
|
||||
scrollbar = ttk.Scrollbar(list_frame, orient=tk.VERTICAL, command=self.agent_tree.yview)
|
||||
self.agent_tree.configure(yscrollcommand=scrollbar.set)
|
||||
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
|
||||
# Details panel
|
||||
details_frame = ttk.Frame(paned)
|
||||
paned.add(details_frame, weight=1)
|
||||
|
||||
ttk.Label(details_frame, text="Details", font=('Arial', 11, 'bold')).pack(anchor=tk.W, pady=5)
|
||||
|
||||
self.agent_details = scrolledtext.ScrolledText(details_frame, wrap=tk.WORD, height=15)
|
||||
self.agent_details.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
# Bind selection
|
||||
self.agent_tree.bind('<<TreeviewSelect>>', self.on_agent_select)
|
||||
|
||||
# Populate list
|
||||
self.populate_agents(self.agents)
|
||||
|
||||
def populate_agents(self, agents: List[Dict]):
|
||||
"""Populate agents list"""
|
||||
for item in self.agent_tree.get_children():
|
||||
self.agent_tree.delete(item)
|
||||
|
||||
for i, agent in enumerate(agents, 1):
|
||||
self.agent_tree.insert('', tk.END, text=str(i), values=(agent['name'], agent['purpose']))
|
||||
|
||||
def filter_agents(self, event=None):
|
||||
"""Filter agents based on search"""
|
||||
query = self.agent_search.get().lower()
|
||||
|
||||
if not query:
|
||||
filtered = self.agents
|
||||
else:
|
||||
filtered = [a for a in self.agents
|
||||
if query in a['name'].lower() or query in a['purpose'].lower()]
|
||||
|
||||
self.populate_agents(filtered)
|
||||
self.agent_count_label.config(text=str(len(filtered)))
|
||||
|
||||
def on_agent_select(self, event):
|
||||
"""Handle agent selection"""
|
||||
selection = self.agent_tree.selection()
|
||||
if not selection:
|
||||
return
|
||||
|
||||
item = self.agent_tree.item(selection[0])
|
||||
agent_name = item['values'][0]
|
||||
|
||||
agent = next((a for a in self.agents if a['name'] == agent_name), None)
|
||||
if agent:
|
||||
details = f"""Agent: {agent['name']}
|
||||
|
||||
Purpose: {agent['purpose']}
|
||||
|
||||
When to Use: {agent['when_to_use']}
|
||||
|
||||
---
|
||||
Usage in Claude Code:
|
||||
Use the /{agent['name']} command or invoke via agent delegation."""
|
||||
self.agent_details.delete('1.0', tk.END)
|
||||
self.agent_details.insert('1.0', details)
|
||||
|
||||
# =========================================================================
|
||||
# SKILLS TAB
|
||||
# =========================================================================
|
||||
|
||||
def create_skills_tab(self):
|
||||
"""Create Skills tab"""
|
||||
frame = ttk.Frame(self.notebook)
|
||||
self.notebook.add(frame, text=f"Skills ({len(self.skills)})")
|
||||
|
||||
# Search and filter
|
||||
filter_frame = ttk.Frame(frame)
|
||||
filter_frame.pack(fill=tk.X, padx=10, pady=10)
|
||||
|
||||
ttk.Label(filter_frame, text="Search:").pack(side=tk.LEFT)
|
||||
self.skill_search = ttk.Entry(filter_frame, width=25)
|
||||
self.skill_search.pack(side=tk.LEFT, padx=5)
|
||||
self.skill_search.bind('<KeyRelease>', self.filter_skills)
|
||||
|
||||
ttk.Label(filter_frame, text="Category:").pack(side=tk.LEFT, padx=(20, 0))
|
||||
self.skill_category = ttk.Combobox(filter_frame, values=['All'] + self.get_categories(), width=15)
|
||||
self.skill_category.set('All')
|
||||
self.skill_category.pack(side=tk.LEFT, padx=5)
|
||||
self.skill_category.bind('<<ComboboxSelected>>', self.filter_skills)
|
||||
|
||||
ttk.Label(filter_frame, text="Count:").pack(side=tk.LEFT, padx=(20, 0))
|
||||
self.skill_count_label = ttk.Label(filter_frame, text=str(len(self.skills)))
|
||||
self.skill_count_label.pack(side=tk.LEFT)
|
||||
|
||||
# Split pane
|
||||
paned = ttk.PanedWindow(frame, orient=tk.HORIZONTAL)
|
||||
paned.pack(fill=tk.BOTH, expand=True, padx=10, pady=(0, 10))
|
||||
|
||||
# Skill list
|
||||
list_frame = ttk.Frame(paned)
|
||||
paned.add(list_frame, weight=1)
|
||||
|
||||
columns = ('name', 'category', 'description')
|
||||
self.skill_tree = ttk.Treeview(list_frame, columns=columns, show='tree headings')
|
||||
self.skill_tree.heading('#0', text='#')
|
||||
self.skill_tree.heading('name', text='Skill Name')
|
||||
self.skill_tree.heading('category', text='Category')
|
||||
self.skill_tree.heading('description', text='Description')
|
||||
|
||||
self.skill_tree.column('#0', width=40)
|
||||
self.skill_tree.column('name', width=180)
|
||||
self.skill_tree.column('category', width=100)
|
||||
self.skill_tree.column('description', width=300)
|
||||
|
||||
self.skill_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||
|
||||
scrollbar = ttk.Scrollbar(list_frame, orient=tk.VERTICAL, command=self.skill_tree.yview)
|
||||
self.skill_tree.configure(yscrollcommand=scrollbar.set)
|
||||
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
|
||||
# Details
|
||||
details_frame = ttk.Frame(paned)
|
||||
paned.add(details_frame, weight=1)
|
||||
|
||||
ttk.Label(details_frame, text="Description", font=('Arial', 11, 'bold')).pack(anchor=tk.W, pady=5)
|
||||
|
||||
self.skill_details = scrolledtext.ScrolledText(details_frame, wrap=tk.WORD, height=15)
|
||||
self.skill_details.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
self.skill_tree.bind('<<TreeviewSelect>>', self.on_skill_select)
|
||||
|
||||
self.populate_skills(self.skills)
|
||||
|
||||
def get_categories(self) -> List[str]:
|
||||
"""Get unique categories from skills"""
|
||||
categories = set(s['category'] for s in self.skills)
|
||||
return sorted(categories)
|
||||
|
||||
def populate_skills(self, skills: List[Dict]):
|
||||
"""Populate skills list"""
|
||||
for item in self.skill_tree.get_children():
|
||||
self.skill_tree.delete(item)
|
||||
|
||||
for i, skill in enumerate(skills, 1):
|
||||
self.skill_tree.insert('', tk.END, text=str(i),
|
||||
values=(skill['name'], skill['category'], skill['description']))
|
||||
|
||||
def filter_skills(self, event=None):
|
||||
"""Filter skills based on search and category"""
|
||||
search = self.skill_search.get().lower()
|
||||
category = self.skill_category.get()
|
||||
|
||||
filtered = self.skills
|
||||
|
||||
if category != 'All':
|
||||
filtered = [s for s in filtered if s['category'] == category]
|
||||
|
||||
if search:
|
||||
filtered = [s for s in filtered
|
||||
if search in s['name'].lower() or search in s['description'].lower()]
|
||||
|
||||
self.populate_skills(filtered)
|
||||
self.skill_count_label.config(text=str(len(filtered)))
|
||||
|
||||
def on_skill_select(self, event):
|
||||
"""Handle skill selection"""
|
||||
selection = self.skill_tree.selection()
|
||||
if not selection:
|
||||
return
|
||||
|
||||
item = self.skill_tree.item(selection[0])
|
||||
skill_name = item['values'][0]
|
||||
|
||||
skill = next((s for s in self.skills if s['name'] == skill_name), None)
|
||||
if skill:
|
||||
details = f"""Skill: {skill['name']}
|
||||
|
||||
Category: {skill['category']}
|
||||
|
||||
Description: {skill['description']}
|
||||
|
||||
Path: {skill['path']}
|
||||
|
||||
---
|
||||
Usage: This skill is automatically activated when working with related technologies."""
|
||||
self.skill_details.delete('1.0', tk.END)
|
||||
self.skill_details.insert('1.0', details)
|
||||
|
||||
# =========================================================================
|
||||
# COMMANDS TAB
|
||||
# =========================================================================
|
||||
|
||||
def create_commands_tab(self):
|
||||
"""Create Commands tab"""
|
||||
frame = ttk.Frame(self.notebook)
|
||||
self.notebook.add(frame, text=f"Commands ({len(self.commands)})")
|
||||
|
||||
# Info
|
||||
info_frame = ttk.Frame(frame)
|
||||
info_frame.pack(fill=tk.X, padx=10, pady=10)
|
||||
|
||||
ttk.Label(info_frame, text="Slash Commands for Claude Code:",
|
||||
font=('Arial', 10, 'bold')).pack(anchor=tk.W)
|
||||
ttk.Label(info_frame, text="Use these commands in Claude Code by typing /command_name",
|
||||
foreground='gray').pack(anchor=tk.W)
|
||||
|
||||
# Commands list
|
||||
list_frame = ttk.Frame(frame)
|
||||
list_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=(0, 10))
|
||||
|
||||
columns = ('name', 'description')
|
||||
self.command_tree = ttk.Treeview(list_frame, columns=columns, show='tree headings')
|
||||
self.command_tree.heading('#0', text='#')
|
||||
self.command_tree.heading('name', text='Command')
|
||||
self.command_tree.heading('description', text='Description')
|
||||
|
||||
self.command_tree.column('#0', width=40)
|
||||
self.command_tree.column('name', width=150)
|
||||
self.command_tree.column('description', width=400)
|
||||
|
||||
self.command_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||
|
||||
scrollbar = ttk.Scrollbar(list_frame, orient=tk.VERTICAL, command=self.command_tree.yview)
|
||||
self.command_tree.configure(yscrollcommand=scrollbar.set)
|
||||
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
|
||||
# Populate
|
||||
for i, cmd in enumerate(self.commands, 1):
|
||||
self.command_tree.insert('', tk.END, text=str(i),
|
||||
values=('/' + cmd['name'], cmd['description']))
|
||||
|
||||
# =========================================================================
|
||||
# RULES TAB
|
||||
# =========================================================================
|
||||
|
||||
def create_rules_tab(self):
|
||||
"""Create Rules tab"""
|
||||
frame = ttk.Frame(self.notebook)
|
||||
self.notebook.add(frame, text=f"Rules ({len(self.rules)})")
|
||||
|
||||
# Info
|
||||
info_frame = ttk.Frame(frame)
|
||||
info_frame.pack(fill=tk.X, padx=10, pady=10)
|
||||
|
||||
ttk.Label(info_frame, text="Coding Rules by Language:",
|
||||
font=('Arial', 10, 'bold')).pack(anchor=tk.W)
|
||||
ttk.Label(info_frame, text="These rules are automatically applied in Claude Code",
|
||||
foreground='gray').pack(anchor=tk.W)
|
||||
|
||||
# Filter
|
||||
filter_frame = ttk.Frame(frame)
|
||||
filter_frame.pack(fill=tk.X, padx=10, pady=5)
|
||||
|
||||
ttk.Label(filter_frame, text="Language:").pack(side=tk.LEFT)
|
||||
self.rules_language = ttk.Combobox(filter_frame,
|
||||
values=['All'] + self.get_rule_languages(),
|
||||
width=15)
|
||||
self.rules_language.set('All')
|
||||
self.rules_language.pack(side=tk.LEFT, padx=5)
|
||||
self.rules_language.bind('<<ComboboxSelected>>', self.filter_rules)
|
||||
|
||||
# Rules list
|
||||
list_frame = ttk.Frame(frame)
|
||||
list_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=(0, 10))
|
||||
|
||||
columns = ('name', 'language')
|
||||
self.rules_tree = ttk.Treeview(list_frame, columns=columns, show='tree headings')
|
||||
self.rules_tree.heading('#0', text='#')
|
||||
self.rules_tree.heading('name', text='Rule Name')
|
||||
self.rules_tree.heading('language', text='Language')
|
||||
|
||||
self.rules_tree.column('#0', width=40)
|
||||
self.rules_tree.column('name', width=250)
|
||||
self.rules_tree.column('language', width=100)
|
||||
|
||||
self.rules_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||
|
||||
scrollbar = ttk.Scrollbar(list_frame, orient=tk.VERTICAL, command=self.rules_tree.yview)
|
||||
self.rules_tree.configure(yscrollcommand=scrollbar.set)
|
||||
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
|
||||
self.populate_rules(self.rules)
|
||||
|
||||
def get_rule_languages(self) -> List[str]:
|
||||
"""Get unique languages from rules"""
|
||||
languages = set(r['language'] for r in self.rules)
|
||||
return sorted(languages)
|
||||
|
||||
def populate_rules(self, rules: List[Dict]):
|
||||
"""Populate rules list"""
|
||||
for item in self.rules_tree.get_children():
|
||||
self.rules_tree.delete(item)
|
||||
|
||||
for i, rule in enumerate(rules, 1):
|
||||
self.rules_tree.insert('', tk.END, text=str(i),
|
||||
values=(rule['name'], rule['language']))
|
||||
|
||||
def filter_rules(self, event=None):
|
||||
"""Filter rules by language"""
|
||||
language = self.rules_language.get()
|
||||
|
||||
if language == 'All':
|
||||
filtered = self.rules
|
||||
else:
|
||||
filtered = [r for r in self.rules if r['language'] == language]
|
||||
|
||||
self.populate_rules(filtered)
|
||||
|
||||
# =========================================================================
|
||||
# SETTINGS TAB
|
||||
# =========================================================================
|
||||
|
||||
def create_settings_tab(self):
|
||||
"""Create Settings tab"""
|
||||
frame = ttk.Frame(self.notebook)
|
||||
self.notebook.add(frame, text="Settings")
|
||||
|
||||
# Project path
|
||||
path_frame = ttk.LabelFrame(frame, text="Project Path", padding=10)
|
||||
path_frame.pack(fill=tk.X, padx=10, pady=10)
|
||||
|
||||
self.path_entry = ttk.Entry(path_frame, width=60)
|
||||
self.path_entry.insert(0, self.project_path)
|
||||
self.path_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)
|
||||
|
||||
ttk.Button(path_frame, text="Browse...", command=self.browse_path).pack(side=tk.LEFT, padx=5)
|
||||
|
||||
# Theme
|
||||
theme_frame = ttk.LabelFrame(frame, text="Appearance", padding=10)
|
||||
theme_frame.pack(fill=tk.X, padx=10, pady=10)
|
||||
|
||||
ttk.Label(theme_frame, text="Theme:").pack(anchor=tk.W)
|
||||
self.theme_var = tk.StringVar(value='light')
|
||||
light_rb = ttk.Radiobutton(theme_frame, text="Light", variable=self.theme_var,
|
||||
value='light', command=self.apply_theme)
|
||||
light_rb.pack(anchor=tk.W)
|
||||
dark_rb = ttk.Radiobutton(theme_frame, text="Dark", variable=self.theme_var,
|
||||
value='dark', command=self.apply_theme)
|
||||
dark_rb.pack(anchor=tk.W)
|
||||
|
||||
font_frame = ttk.LabelFrame(frame, text="Font", padding=10)
|
||||
font_frame.pack(fill=tk.X, padx=10, pady=10)
|
||||
|
||||
ttk.Label(font_frame, text="Font Family:").pack(anchor=tk.W)
|
||||
self.font_var = tk.StringVar(value='Open Sans')
|
||||
|
||||
fonts = ['Open Sans', 'Arial', 'Helvetica', 'Times New Roman', 'Courier New', 'Verdana', 'Georgia', 'Tahoma', 'Trebuchet MS']
|
||||
self.font_combo = ttk.Combobox(font_frame, textvariable=self.font_var, values=fonts, state='readonly')
|
||||
self.font_combo.pack(anchor=tk.W, fill=tk.X, pady=(5, 0))
|
||||
self.font_combo.bind('<<ComboboxSelected>>', lambda e: self.apply_theme())
|
||||
|
||||
ttk.Label(font_frame, text="Font Size:").pack(anchor=tk.W, pady=(10, 0))
|
||||
self.size_var = tk.StringVar(value='10')
|
||||
sizes = ['8', '9', '10', '11', '12', '14', '16', '18', '20']
|
||||
self.size_combo = ttk.Combobox(font_frame, textvariable=self.size_var, values=sizes, state='readonly', width=10)
|
||||
self.size_combo.pack(anchor=tk.W, pady=(5, 0))
|
||||
self.size_combo.bind('<<ComboboxSelected>>', lambda e: self.apply_theme())
|
||||
|
||||
# Quick Actions
|
||||
actions_frame = ttk.LabelFrame(frame, text="Quick Actions", padding=10)
|
||||
actions_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
|
||||
|
||||
ttk.Button(actions_frame, text="Open Project in Terminal",
|
||||
command=self.open_terminal).pack(fill=tk.X, pady=2)
|
||||
ttk.Button(actions_frame, text="Open README",
|
||||
command=self.open_readme).pack(fill=tk.X, pady=2)
|
||||
ttk.Button(actions_frame, text="Open AGENTS.md",
|
||||
command=self.open_agents).pack(fill=tk.X, pady=2)
|
||||
ttk.Button(actions_frame, text="Refresh Data",
|
||||
command=self.refresh_data).pack(fill=tk.X, pady=2)
|
||||
|
||||
# About
|
||||
about_frame = ttk.LabelFrame(frame, text="About", padding=10)
|
||||
about_frame.pack(fill=tk.X, padx=10, pady=10)
|
||||
|
||||
about_text = """ECC Dashboard v1.0.0
|
||||
Everything Claude Code GUI
|
||||
|
||||
A cross-platform desktop application for
|
||||
managing and exploring ECC components.
|
||||
|
||||
Version: 1.10.0
|
||||
Project: github.com/affaan-m/everything-claude-code"""
|
||||
|
||||
ttk.Label(about_frame, text=about_text, justify=tk.LEFT).pack(anchor=tk.W)
|
||||
|
||||
def browse_path(self):
|
||||
"""Browse for project path"""
|
||||
from tkinter import filedialog
|
||||
path = filedialog.askdirectory(initialdir=self.project_path)
|
||||
if path:
|
||||
self.path_entry.delete(0, tk.END)
|
||||
self.path_entry.insert(0, path)
|
||||
|
||||
def open_terminal(self):
|
||||
"""Open terminal at project path"""
|
||||
import subprocess
|
||||
path = self.path_entry.get()
|
||||
if os.name == 'nt': # Windows
|
||||
subprocess.Popen(['cmd', '/c', 'start', 'cmd', '/k', f'cd /d "{path}"'])
|
||||
elif os.uname().sysname == 'Darwin': # macOS
|
||||
subprocess.Popen(['open', '-a', 'Terminal', path])
|
||||
else: # Linux
|
||||
subprocess.Popen(['x-terminal-emulator', '-e', f'cd {path}'])
|
||||
|
||||
def open_readme(self):
|
||||
"""Open README in default browser/reader"""
|
||||
import subprocess
|
||||
path = os.path.join(self.path_entry.get(), 'README.md')
|
||||
if os.path.exists(path):
|
||||
subprocess.Popen(['xdg-open' if os.name != 'nt' else 'start', path])
|
||||
else:
|
||||
messagebox.showerror("Error", "README.md not found")
|
||||
|
||||
def open_agents(self):
|
||||
"""Open AGENTS.md"""
|
||||
import subprocess
|
||||
path = os.path.join(self.path_entry.get(), 'AGENTS.md')
|
||||
if os.path.exists(path):
|
||||
subprocess.Popen(['xdg-open' if os.name != 'nt' else 'start', path])
|
||||
else:
|
||||
messagebox.showerror("Error", "AGENTS.md not found")
|
||||
|
||||
def refresh_data(self):
|
||||
"""Refresh all data"""
|
||||
self.project_path = self.path_entry.get()
|
||||
self.agents = load_agents(self.project_path)
|
||||
self.skills = load_skills(self.project_path)
|
||||
self.commands = load_commands(self.project_path)
|
||||
self.rules = load_rules(self.project_path)
|
||||
|
||||
# Update tabs
|
||||
self.notebook.tab(0, text=f"Agents ({len(self.agents)})")
|
||||
self.notebook.tab(1, text=f"Skills ({len(self.skills)})")
|
||||
self.notebook.tab(2, text=f"Commands ({len(self.commands)})")
|
||||
self.notebook.tab(3, text=f"Rules ({len(self.rules)})")
|
||||
|
||||
# Repopulate
|
||||
self.populate_agents(self.agents)
|
||||
self.populate_skills(self.skills)
|
||||
|
||||
# Update status
|
||||
self.status_label.config(
|
||||
text=f"Ready | Agents: {len(self.agents)} | Skills: {len(self.skills)} | Commands: {len(self.commands)}"
|
||||
)
|
||||
|
||||
messagebox.showinfo("Success", "Data refreshed successfully!")
|
||||
|
||||
def apply_theme(self):
|
||||
theme = self.theme_var.get()
|
||||
font_family = self.font_var.get()
|
||||
font_size = int(self.size_var.get())
|
||||
font_tuple = (font_family, font_size)
|
||||
|
||||
if theme == 'dark':
|
||||
bg_color = '#2b2b2b'
|
||||
fg_color = '#ffffff'
|
||||
entry_bg = '#3c3c3c'
|
||||
frame_bg = '#2b2b2b'
|
||||
select_bg = '#0f5a9e'
|
||||
else:
|
||||
bg_color = '#f0f0f0'
|
||||
fg_color = '#000000'
|
||||
entry_bg = '#ffffff'
|
||||
frame_bg = '#f0f0f0'
|
||||
select_bg = '#e0e0e0'
|
||||
|
||||
self.configure(background=bg_color)
|
||||
|
||||
style = ttk.Style()
|
||||
style.configure('.', background=bg_color, foreground=fg_color, font=font_tuple)
|
||||
style.configure('TFrame', background=bg_color, font=font_tuple)
|
||||
style.configure('TLabel', background=bg_color, foreground=fg_color, font=font_tuple)
|
||||
style.configure('TNotebook', background=bg_color, font=font_tuple)
|
||||
style.configure('TNotebook.Tab', background=frame_bg, foreground=fg_color, font=font_tuple)
|
||||
style.map('TNotebook.Tab', background=[('selected', select_bg)])
|
||||
style.configure('Treeview', background=entry_bg, foreground=fg_color, fieldbackground=entry_bg, font=font_tuple)
|
||||
style.configure('Treeview.Heading', background=frame_bg, foreground=fg_color, font=font_tuple)
|
||||
style.configure('TEntry', fieldbackground=entry_bg, foreground=fg_color, font=font_tuple)
|
||||
style.configure('TButton', background=frame_bg, foreground=fg_color, font=font_tuple)
|
||||
|
||||
self.title_label.configure(font=(font_family, 18, 'bold'))
|
||||
self.version_label.configure(font=(font_family, 10))
|
||||
|
||||
def update_widget_colors(widget):
|
||||
try:
|
||||
widget.configure(background=bg_color)
|
||||
except:
|
||||
pass
|
||||
for child in widget.winfo_children():
|
||||
try:
|
||||
child.configure(background=bg_color)
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
update_widget_colors(child)
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
update_widget_colors(self)
|
||||
except:
|
||||
pass
|
||||
|
||||
self.update()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MAIN
|
||||
# ============================================================================
|
||||
|
||||
def main():
|
||||
"""Main entry point"""
|
||||
app = ECCDashboard()
|
||||
app.mainloop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -16,6 +16,22 @@ User request → Claude picks a tool → PreToolUse hook runs → Tool executes
|
||||
|
||||
## Hooks in This Plugin
|
||||
|
||||
## 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.
|
||||
|
||||
Use the installer instead so hook commands are rewritten against your actual Claude root:
|
||||
|
||||
```bash
|
||||
bash ./install.sh --target claude --modules hooks-runtime
|
||||
```
|
||||
|
||||
```powershell
|
||||
pwsh -File .\install.ps1 --target claude --modules hooks-runtime
|
||||
```
|
||||
|
||||
That installs resolved hooks to `~/.claude/hooks/hooks.json`. On Windows, the Claude config root is `%USERPROFILE%\\.claude`.
|
||||
|
||||
### PreToolUse Hooks
|
||||
|
||||
| Hook | Matcher | Behavior | Exit Code |
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "npx block-no-verify@1.1.2"
|
||||
"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\""
|
||||
}
|
||||
],
|
||||
"description": "Block git hook-bypass flag to protect pre-commit, commit-msg, and pre-push hooks from being skipped",
|
||||
@@ -126,6 +126,30 @@
|
||||
],
|
||||
"description": "Check MCP server health before MCP tool execution and block unhealthy MCP calls",
|
||||
"id": "pre:mcp-health-check"
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"pre:edit-write:gateguard-fact-force\" \"scripts/hooks/gateguard-fact-force.js\" \"standard,strict\"",
|
||||
"timeout": 5
|
||||
}
|
||||
],
|
||||
"description": "Fact-forcing gate: block first Edit/Write/MultiEdit per file and demand investigation (importers, data schemas, user instruction) before allowing",
|
||||
"id": "pre:edit-write:gateguard-fact-force"
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"pre:bash:gateguard-fact-force\" \"scripts/hooks/gateguard-fact-force.js\" \"standard,strict\"",
|
||||
"timeout": 5
|
||||
}
|
||||
],
|
||||
"description": "Fact-forcing gate: block destructive Bash commands and demand rollback plan; quote user instruction on first Bash per session",
|
||||
"id": "pre:bash:gateguard-fact-force"
|
||||
}
|
||||
],
|
||||
"PreCompact": [
|
||||
|
||||
216
package.json
216
package.json
@@ -39,65 +39,198 @@
|
||||
},
|
||||
"files": [
|
||||
".agents/",
|
||||
".claude-plugin/",
|
||||
".codex/",
|
||||
".codex-plugin/",
|
||||
".cursor/",
|
||||
".opencode/commands/",
|
||||
".opencode/dist/",
|
||||
".opencode/instructions/",
|
||||
".opencode/plugins/",
|
||||
".opencode/prompts/",
|
||||
".opencode/tools/",
|
||||
".opencode/index.ts",
|
||||
".opencode/opencode.json",
|
||||
".opencode/package.json",
|
||||
".opencode/tsconfig.json",
|
||||
".opencode/MIGRATION.md",
|
||||
".opencode/README.md",
|
||||
".gemini/",
|
||||
".opencode/",
|
||||
".mcp.json",
|
||||
"AGENTS.md",
|
||||
"VERSION",
|
||||
"agent.yaml",
|
||||
"agents/",
|
||||
"commands/",
|
||||
"contexts/",
|
||||
"examples/CLAUDE.md",
|
||||
"examples/user-CLAUDE.md",
|
||||
"examples/statusline.json",
|
||||
"hooks/",
|
||||
"install.ps1",
|
||||
"install.sh",
|
||||
"manifests/",
|
||||
"mcp-configs/",
|
||||
"plugins/",
|
||||
"rules/",
|
||||
"schemas/",
|
||||
"scripts/ci/",
|
||||
"scripts/catalog.js",
|
||||
"scripts/claw.js",
|
||||
"scripts/codex/merge-codex-config.js",
|
||||
"scripts/codex/merge-mcp-config.js",
|
||||
"scripts/doctor.js",
|
||||
"scripts/ecc.js",
|
||||
"scripts/gemini-adapt-agents.js",
|
||||
"scripts/harness-audit.js",
|
||||
"scripts/hooks/",
|
||||
"scripts/lib/",
|
||||
"scripts/claw.js",
|
||||
"scripts/doctor.js",
|
||||
"scripts/status.js",
|
||||
"scripts/sessions-cli.js",
|
||||
"scripts/install-apply.js",
|
||||
"scripts/install-plan.js",
|
||||
"scripts/lib/",
|
||||
"scripts/list-installed.js",
|
||||
"scripts/orchestration-status.js",
|
||||
"scripts/orchestrate-codex-worker.sh",
|
||||
"scripts/orchestrate-worktrees.js",
|
||||
"scripts/repair.js",
|
||||
"scripts/session-inspect.js",
|
||||
"scripts/sessions-cli.js",
|
||||
"scripts/setup-package-manager.js",
|
||||
"scripts/skill-create-output.js",
|
||||
"scripts/codex/merge-codex-config.js",
|
||||
"scripts/codex/merge-mcp-config.js",
|
||||
"scripts/repair.js",
|
||||
"scripts/harness-audit.js",
|
||||
"scripts/session-inspect.js",
|
||||
"scripts/status.js",
|
||||
"scripts/uninstall.js",
|
||||
"skills/",
|
||||
"AGENTS.md",
|
||||
".claude-plugin/plugin.json",
|
||||
".claude-plugin/README.md",
|
||||
".codex-plugin/plugin.json",
|
||||
".codex-plugin/README.md",
|
||||
".mcp.json",
|
||||
"install.sh",
|
||||
"install.ps1",
|
||||
"llms.txt"
|
||||
"skills/agent-harness-construction/",
|
||||
"skills/agent-introspection-debugging/",
|
||||
"skills/agent-sort/",
|
||||
"skills/agentic-engineering/",
|
||||
"skills/ai-first-engineering/",
|
||||
"skills/ai-regression-testing/",
|
||||
"skills/android-clean-architecture/",
|
||||
"skills/api-connector-builder/",
|
||||
"skills/api-design/",
|
||||
"skills/article-writing/",
|
||||
"skills/automation-audit-ops/",
|
||||
"skills/autonomous-loops/",
|
||||
"skills/backend-patterns/",
|
||||
"skills/blueprint/",
|
||||
"skills/brand-voice/",
|
||||
"skills/carrier-relationship-management/",
|
||||
"skills/claude-api/",
|
||||
"skills/claude-devfleet/",
|
||||
"skills/clickhouse-io/",
|
||||
"skills/code-tour/",
|
||||
"skills/coding-standards/",
|
||||
"skills/compose-multiplatform-patterns/",
|
||||
"skills/configure-ecc/",
|
||||
"skills/connections-optimizer/",
|
||||
"skills/content-engine/",
|
||||
"skills/content-hash-cache-pattern/",
|
||||
"skills/continuous-agent-loop/",
|
||||
"skills/continuous-learning/",
|
||||
"skills/continuous-learning-v2/",
|
||||
"skills/cost-aware-llm-pipeline/",
|
||||
"skills/council/",
|
||||
"skills/cpp-coding-standards/",
|
||||
"skills/cpp-testing/",
|
||||
"skills/crosspost/",
|
||||
"skills/csharp-testing/",
|
||||
"skills/customer-billing-ops/",
|
||||
"skills/customs-trade-compliance/",
|
||||
"skills/dart-flutter-patterns/",
|
||||
"skills/dashboard-builder/",
|
||||
"skills/data-scraper-agent/",
|
||||
"skills/database-migrations/",
|
||||
"skills/deep-research/",
|
||||
"skills/defi-amm-security/",
|
||||
"skills/deployment-patterns/",
|
||||
"skills/django-patterns/",
|
||||
"skills/django-security/",
|
||||
"skills/django-tdd/",
|
||||
"skills/django-verification/",
|
||||
"skills/dmux-workflows/",
|
||||
"skills/docker-patterns/",
|
||||
"skills/dotnet-patterns/",
|
||||
"skills/e2e-testing/",
|
||||
"skills/ecc-tools-cost-audit/",
|
||||
"skills/email-ops/",
|
||||
"skills/energy-procurement/",
|
||||
"skills/enterprise-agent-ops/",
|
||||
"skills/eval-harness/",
|
||||
"skills/evm-token-decimals/",
|
||||
"skills/exa-search/",
|
||||
"skills/fal-ai-media/",
|
||||
"skills/finance-billing-ops/",
|
||||
"skills/foundation-models-on-device/",
|
||||
"skills/frontend-design/",
|
||||
"skills/frontend-patterns/",
|
||||
"skills/frontend-slides/",
|
||||
"skills/github-ops/",
|
||||
"skills/golang-patterns/",
|
||||
"skills/golang-testing/",
|
||||
"skills/google-workspace-ops/",
|
||||
"skills/healthcare-phi-compliance/",
|
||||
"skills/hipaa-compliance/",
|
||||
"skills/hookify-rules/",
|
||||
"skills/inventory-demand-planning/",
|
||||
"skills/investor-materials/",
|
||||
"skills/investor-outreach/",
|
||||
"skills/iterative-retrieval/",
|
||||
"skills/java-coding-standards/",
|
||||
"skills/jira-integration/",
|
||||
"skills/jpa-patterns/",
|
||||
"skills/knowledge-ops/",
|
||||
"skills/kotlin-coroutines-flows/",
|
||||
"skills/kotlin-exposed-patterns/",
|
||||
"skills/kotlin-ktor-patterns/",
|
||||
"skills/kotlin-patterns/",
|
||||
"skills/kotlin-testing/",
|
||||
"skills/laravel-patterns/",
|
||||
"skills/laravel-plugin-discovery/",
|
||||
"skills/laravel-security/",
|
||||
"skills/laravel-tdd/",
|
||||
"skills/laravel-verification/",
|
||||
"skills/lead-intelligence/",
|
||||
"skills/liquid-glass-design/",
|
||||
"skills/llm-trading-agent-security/",
|
||||
"skills/logistics-exception-management/",
|
||||
"skills/manim-video/",
|
||||
"skills/market-research/",
|
||||
"skills/mcp-server-patterns/",
|
||||
"skills/messages-ops/",
|
||||
"skills/nanoclaw-repl/",
|
||||
"skills/nestjs-patterns/",
|
||||
"skills/nodejs-keccak256/",
|
||||
"skills/nutrient-document-processing/",
|
||||
"skills/perl-patterns/",
|
||||
"skills/perl-security/",
|
||||
"skills/perl-testing/",
|
||||
"skills/plankton-code-quality/",
|
||||
"skills/postgres-patterns/",
|
||||
"skills/product-capability/",
|
||||
"skills/production-scheduling/",
|
||||
"skills/project-flow-ops/",
|
||||
"skills/prompt-optimizer/",
|
||||
"skills/python-patterns/",
|
||||
"skills/python-testing/",
|
||||
"skills/quality-nonconformance/",
|
||||
"skills/ralphinho-rfc-pipeline/",
|
||||
"skills/regex-vs-llm-structured-text/",
|
||||
"skills/remotion-video-creation/",
|
||||
"skills/research-ops/",
|
||||
"skills/returns-reverse-logistics/",
|
||||
"skills/rust-patterns/",
|
||||
"skills/rust-testing/",
|
||||
"skills/search-first/",
|
||||
"skills/security-bounty-hunter/",
|
||||
"skills/security-review/",
|
||||
"skills/security-scan/",
|
||||
"skills/seo/",
|
||||
"skills/skill-stocktake/",
|
||||
"skills/social-graph-ranker/",
|
||||
"skills/springboot-patterns/",
|
||||
"skills/springboot-security/",
|
||||
"skills/springboot-tdd/",
|
||||
"skills/springboot-verification/",
|
||||
"skills/strategic-compact/",
|
||||
"skills/swift-actor-persistence/",
|
||||
"skills/swift-concurrency-6-2/",
|
||||
"skills/swift-protocol-di-testing/",
|
||||
"skills/swiftui-patterns/",
|
||||
"skills/tdd-workflow/",
|
||||
"skills/team-builder/",
|
||||
"skills/terminal-ops/",
|
||||
"skills/token-budget-advisor/",
|
||||
"skills/ui-demo/",
|
||||
"skills/unified-notifications-ops/",
|
||||
"skills/verification-loop/",
|
||||
"skills/video-editing/",
|
||||
"skills/videodb/",
|
||||
"skills/visa-doc-translate/",
|
||||
"skills/workspace-surface-audit/",
|
||||
"skills/x-api/",
|
||||
"the-security-guide.md"
|
||||
],
|
||||
"bin": {
|
||||
"ecc": "scripts/ecc.js",
|
||||
@@ -116,7 +249,8 @@
|
||||
"test": "node scripts/ci/check-unicode-safety.js && node scripts/ci/validate-agents.js && node scripts/ci/validate-commands.js && node scripts/ci/validate-rules.js && node scripts/ci/validate-skills.js && node scripts/ci/validate-hooks.js && node scripts/ci/validate-install-manifests.js && node scripts/ci/validate-no-personal-paths.js && npm run catalog:check && node tests/run-all.js",
|
||||
"coverage": "c8 --all --include=\"scripts/**/*.js\" --check-coverage --lines 80 --functions 80 --branches 80 --statements 80 --reporter=text --reporter=lcov node tests/run-all.js",
|
||||
"build:opencode": "node scripts/build-opencode.js",
|
||||
"prepack": "npm run build:opencode"
|
||||
"prepack": "npm run build:opencode",
|
||||
"dashboard": "python3 ./ecc_dashboard.py"
|
||||
},
|
||||
"dependencies": {
|
||||
"@iarna/toml": "^2.2.5",
|
||||
@@ -126,7 +260,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@opencode-ai/plugin": "^1.0.0",
|
||||
"@types/node": "^20.19.24",
|
||||
"@types/node": "^25.6.0",
|
||||
"c8": "^11.0.0",
|
||||
"eslint": "^9.39.2",
|
||||
"globals": "^17.4.0",
|
||||
@@ -137,4 +271,4 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"packageManager": "yarn@4.9.2+sha512.1fc009bc09d13cfd0e19efa44cbfc2b9cf6ca61482725eb35bbc5e257e093ebf4130db6dfe15d604ff4b79efd8e1e8e99b25fa7d0a6197c9f9826358d4d65c3c"
|
||||
}
|
||||
}
|
||||
78
pyproject.toml
Normal file
78
pyproject.toml
Normal file
@@ -0,0 +1,78 @@
|
||||
[project]
|
||||
name = "llm-abstraction"
|
||||
version = "0.1.0"
|
||||
description = "Provider-agnostic LLM abstraction layer"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
license = {text = "MIT"}
|
||||
authors = [
|
||||
{name = "Affaan Mustafa", email = "affaan@example.com"}
|
||||
]
|
||||
keywords = ["llm", "openai", "anthropic", "ollama", "ai"]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"anthropic>=0.25.0",
|
||||
"openai>=1.30.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.23",
|
||||
"pytest-cov>=4.1",
|
||||
"pytest-mock>=3.12",
|
||||
"ruff>=0.4",
|
||||
"mypy>=1.10",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/affaan-m/everything-claude-code"
|
||||
Repository = "https://github.com/affaan-m/everything-claude-code"
|
||||
|
||||
[project.scripts]
|
||||
llm-select = "llm.cli.selector:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/llm"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
filterwarnings = ["ignore::DeprecationWarning"]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["src/llm"]
|
||||
branch = true
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"if TYPE_CHECKING:",
|
||||
"raise NotImplementedError",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
src-path = ["src"]
|
||||
target-version = "py311"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "N", "W", "UP"]
|
||||
ignore = ["E501"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
src_paths = ["src"]
|
||||
warn_return_any = true
|
||||
warn_unused_ignores = true
|
||||
138
scripts/ci/validate-workflow-security.js
Normal file
138
scripts/ci/validate-workflow-security.js
Normal file
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Reject unsafe GitHub Actions patterns that execute or checkout untrusted PR code
|
||||
* from privileged events such as workflow_run or pull_request_target.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const DEFAULT_WORKFLOWS_DIR = path.join(__dirname, '../../.github/workflows');
|
||||
|
||||
const RULES = [
|
||||
{
|
||||
event: 'workflow_run',
|
||||
eventPattern: /\bworkflow_run\s*:/m,
|
||||
description: 'workflow_run must not checkout an untrusted workflow_run head ref/repository',
|
||||
expressionPattern: /\$\{\{\s*github\.event\.workflow_run\.(?:head_branch|head_sha|head_repository(?:\.[A-Za-z0-9_.]+)?)\s*\}\}|\$\{\{\s*github\.event\.workflow_run\.pull_requests\[\d+\]\.head\.(?:ref|sha|repo\.full_name)\s*\}\}/g,
|
||||
},
|
||||
{
|
||||
event: 'pull_request_target',
|
||||
eventPattern: /\bpull_request_target\s*:/m,
|
||||
description: 'pull_request_target must not checkout an untrusted pull_request head ref/repository',
|
||||
expressionPattern: /\$\{\{\s*github\.event\.pull_request\.head\.(?:ref|sha|repo\.full_name)\s*\}\}/g,
|
||||
},
|
||||
];
|
||||
|
||||
function getWorkflowFiles(workflowsDir) {
|
||||
if (!fs.existsSync(workflowsDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs.readdirSync(workflowsDir)
|
||||
.filter(file => /\.(?:yml|yaml)$/i.test(file))
|
||||
.map(file => path.join(workflowsDir, file))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function getLineNumber(source, index) {
|
||||
return source.slice(0, index).split(/\r?\n/).length;
|
||||
}
|
||||
|
||||
function extractCheckoutSteps(source) {
|
||||
const blocks = [];
|
||||
const lines = source.split(/\r?\n/);
|
||||
let current = null;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const stepStart = line.match(/^(\s*)-\s+/);
|
||||
|
||||
if (stepStart) {
|
||||
if (current) {
|
||||
blocks.push(current);
|
||||
}
|
||||
|
||||
current = {
|
||||
indent: stepStart[1].length,
|
||||
startLine: i + 1,
|
||||
lines: [line],
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current) {
|
||||
current.lines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (current) {
|
||||
blocks.push(current);
|
||||
}
|
||||
|
||||
return blocks
|
||||
.map(block => ({
|
||||
startLine: block.startLine,
|
||||
text: block.lines.join('\n'),
|
||||
}))
|
||||
.filter(block => /uses:\s*actions\/checkout@/m.test(block.text));
|
||||
}
|
||||
|
||||
function findViolations(filePath, source) {
|
||||
const violations = [];
|
||||
const checkoutSteps = extractCheckoutSteps(source);
|
||||
|
||||
for (const rule of RULES) {
|
||||
if (!rule.eventPattern.test(source)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const step of checkoutSteps) {
|
||||
for (const match of step.text.matchAll(rule.expressionPattern)) {
|
||||
violations.push({
|
||||
filePath,
|
||||
event: rule.event,
|
||||
description: rule.description,
|
||||
expression: match[0],
|
||||
line: step.startLine + getLineNumber(step.text, match.index) - 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
function validateWorkflowSecurity(workflowsDir = DEFAULT_WORKFLOWS_DIR) {
|
||||
const files = getWorkflowFiles(workflowsDir);
|
||||
const violations = [];
|
||||
|
||||
for (const filePath of files) {
|
||||
const source = fs.readFileSync(filePath, 'utf8');
|
||||
violations.push(...findViolations(filePath, source));
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
for (const violation of violations) {
|
||||
console.error(
|
||||
`ERROR: ${path.basename(violation.filePath)}:${violation.line} - ${violation.description}`,
|
||||
);
|
||||
console.error(` Unsafe expression: ${violation.expression}`);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
console.log(`Validated workflow security for ${files.length} workflow files`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
process.exit(validateWorkflowSecurity(process.env.ECC_WORKFLOWS_DIR || DEFAULT_WORKFLOWS_DIR));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_WORKFLOWS_DIR,
|
||||
extractCheckoutSteps,
|
||||
findViolations,
|
||||
validateWorkflowSecurity,
|
||||
};
|
||||
269
scripts/hooks/block-no-verify.js
Normal file
269
scripts/hooks/block-no-verify.js
Normal file
@@ -0,0 +1,269 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* PreToolUse Hook: Block --no-verify flag
|
||||
*
|
||||
* Blocks git hook-bypass flags (--no-verify, -c core.hooksPath=) to protect
|
||||
* pre-commit, commit-msg, and pre-push hooks from being skipped by AI agents.
|
||||
*
|
||||
* Replaces the previous npx-based invocation that failed in pnpm-only projects
|
||||
* (EBADDEVENGINES) and could not be disabled via ECC_DISABLED_HOOKS.
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 = allow (not a git command or no bypass flags)
|
||||
* 2 = block (bypass flag detected)
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
let raw = '';
|
||||
|
||||
/**
|
||||
* Git commands that support the --no-verify flag.
|
||||
*/
|
||||
const GIT_COMMANDS_WITH_NO_VERIFY = [
|
||||
'commit',
|
||||
'push',
|
||||
'merge',
|
||||
'cherry-pick',
|
||||
'rebase',
|
||||
'am',
|
||||
];
|
||||
|
||||
/**
|
||||
* Characters that can appear immediately before 'git' in a command string.
|
||||
*/
|
||||
const VALID_BEFORE_GIT = ' \t\n\r;&|$`(<{!"\']/.~\\';
|
||||
|
||||
/**
|
||||
* Check if a position in the input is inside a shell comment.
|
||||
*/
|
||||
function isInComment(input, idx) {
|
||||
const lineStart = input.lastIndexOf('\n', idx - 1) + 1;
|
||||
const before = input.slice(lineStart, idx);
|
||||
for (let i = 0; i < before.length; i++) {
|
||||
if (before.charAt(i) === '#') {
|
||||
const prev = i > 0 ? before.charAt(i - 1) : '';
|
||||
if (prev !== '$' && prev !== '\\') return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the next 'git' token in the input starting from a position.
|
||||
*/
|
||||
function findGit(input, start) {
|
||||
let pos = start;
|
||||
while (pos < input.length) {
|
||||
const idx = input.indexOf('git', pos);
|
||||
if (idx === -1) return null;
|
||||
|
||||
const isExe = input.slice(idx + 3, idx + 7).toLowerCase() === '.exe';
|
||||
const len = isExe ? 7 : 3;
|
||||
const after = input[idx + len] || ' ';
|
||||
if (!/[\s"']/.test(after)) {
|
||||
pos = idx + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const before = idx > 0 ? input[idx - 1] : ' ';
|
||||
if (VALID_BEFORE_GIT.includes(before)) return { idx, len };
|
||||
pos = idx + 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect which git subcommand (commit, push, etc.) is being invoked.
|
||||
* Returns { command, offset } where offset is the position right after the
|
||||
* subcommand keyword, so callers can scope flag checks to only that portion.
|
||||
*/
|
||||
function detectGitCommand(input) {
|
||||
let start = 0;
|
||||
while (start < input.length) {
|
||||
const git = findGit(input, start);
|
||||
if (!git) return null;
|
||||
|
||||
if (isInComment(input, git.idx)) {
|
||||
start = git.idx + git.len;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the first matching subcommand token after "git".
|
||||
// We pick the one closest to "git" so that argument values like
|
||||
// "git push origin commit" don't misclassify "commit" as the subcommand.
|
||||
let bestCmd = null;
|
||||
let bestIdx = Infinity;
|
||||
|
||||
for (const cmd of GIT_COMMANDS_WITH_NO_VERIFY) {
|
||||
let searchPos = git.idx + git.len;
|
||||
while (searchPos < input.length) {
|
||||
const cmdIdx = input.indexOf(cmd, searchPos);
|
||||
if (cmdIdx === -1) break;
|
||||
|
||||
const before = cmdIdx > 0 ? input[cmdIdx - 1] : ' ';
|
||||
const after = input[cmdIdx + cmd.length] || ' ';
|
||||
if (!/\s/.test(before)) { searchPos = cmdIdx + 1; continue; }
|
||||
if (!/[\s;&#|>)\]}"']/.test(after) && after !== '') { searchPos = cmdIdx + 1; continue; }
|
||||
if (/[;|]/.test(input.slice(git.idx + git.len, cmdIdx))) break;
|
||||
if (isInComment(input, cmdIdx)) { searchPos = cmdIdx + 1; continue; }
|
||||
|
||||
// Verify this token is the first non-flag word after "git" — i.e. the
|
||||
// actual subcommand, not an argument value to a different subcommand.
|
||||
const gap = input.slice(git.idx + git.len, cmdIdx);
|
||||
const tokens = gap.trim().split(/\s+/).filter(Boolean);
|
||||
// Every token before the candidate must be a flag or a flag argument.
|
||||
// Git global flags like -c take a value argument (e.g. -c key=value).
|
||||
let onlyFlagsAndArgs = true;
|
||||
let expectFlagArg = false;
|
||||
for (const t of tokens) {
|
||||
if (expectFlagArg) { expectFlagArg = false; continue; }
|
||||
if (t.startsWith('-')) {
|
||||
// -c is a git global flag that takes the next token as its argument
|
||||
if (t === '-c' || t === '-C' || t === '--work-tree' || t === '--git-dir' ||
|
||||
t === '--namespace' || t === '--super-prefix') {
|
||||
expectFlagArg = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
onlyFlagsAndArgs = false;
|
||||
break;
|
||||
}
|
||||
if (!onlyFlagsAndArgs) { searchPos = cmdIdx + 1; continue; }
|
||||
|
||||
if (cmdIdx < bestIdx) {
|
||||
bestIdx = cmdIdx;
|
||||
bestCmd = cmd;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestCmd) {
|
||||
return { command: bestCmd, offset: bestIdx + bestCmd.length };
|
||||
}
|
||||
|
||||
start = git.idx + git.len;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the input contains a --no-verify flag for a specific git command.
|
||||
* Only inspects the portion of the input starting at `offset` (the position
|
||||
* right after the detected subcommand keyword) so that flags belonging to
|
||||
* earlier commands in a chain are not falsely matched.
|
||||
*/
|
||||
function hasNoVerifyFlag(input, command, offset) {
|
||||
const region = input.slice(offset);
|
||||
if (/--no-verify\b/.test(region)) return true;
|
||||
|
||||
// For commit, -n is shorthand for --no-verify
|
||||
if (command === 'commit') {
|
||||
if (/\s-n(?:\s|$)/.test(region) || /\s-n[a-zA-Z]/.test(region)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the input contains a -c core.hooksPath= override.
|
||||
*/
|
||||
function hasHooksPathOverride(input) {
|
||||
return /-c\s+["']?core\.hooksPath\s*=/.test(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a command string for git hook bypass attempts.
|
||||
*/
|
||||
function checkCommand(input) {
|
||||
const detected = detectGitCommand(input);
|
||||
if (!detected) return { blocked: false };
|
||||
|
||||
const { command: gitCommand, offset } = detected;
|
||||
|
||||
if (hasNoVerifyFlag(input, gitCommand, offset)) {
|
||||
return {
|
||||
blocked: true,
|
||||
reason: `BLOCKED: --no-verify flag is not allowed with git ${gitCommand}. Git hooks must not be bypassed.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (hasHooksPathOverride(input)) {
|
||||
return {
|
||||
blocked: true,
|
||||
reason: `BLOCKED: Overriding core.hooksPath is not allowed with git ${gitCommand}. Git hooks must not be bypassed.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { blocked: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the command string from hook input (JSON or plain text).
|
||||
*/
|
||||
function extractCommand(rawInput) {
|
||||
const trimmed = rawInput.trim();
|
||||
if (!trimmed.startsWith('{')) return trimmed;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed !== 'object' || parsed === null) return trimmed;
|
||||
|
||||
// Claude Code format: { tool_input: { command: "..." } }
|
||||
const cmd = parsed.tool_input?.command;
|
||||
if (typeof cmd === 'string') return cmd;
|
||||
|
||||
// Generic JSON formats
|
||||
for (const key of ['command', 'cmd', 'input', 'shell', 'script']) {
|
||||
if (typeof parsed[key] === 'string') return parsed[key];
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exportable run() for in-process execution via run-with-flags.js.
|
||||
*/
|
||||
function run(rawInput) {
|
||||
const command = extractCommand(rawInput);
|
||||
const result = checkCommand(command);
|
||||
|
||||
if (result.blocked) {
|
||||
return {
|
||||
exitCode: 2,
|
||||
stderr: result.reason,
|
||||
};
|
||||
}
|
||||
|
||||
return { exitCode: 0 };
|
||||
}
|
||||
|
||||
module.exports = { run };
|
||||
|
||||
// Stdin fallback for spawnSync execution — only when invoked directly, not via require()
|
||||
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 command = extractCommand(raw);
|
||||
const result = checkCommand(command);
|
||||
|
||||
if (result.blocked) {
|
||||
process.stderr.write(result.reason + '\n');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
process.stdout.write(raw);
|
||||
});
|
||||
}
|
||||
265
scripts/hooks/gateguard-fact-force.js
Normal file
265
scripts/hooks/gateguard-fact-force.js
Normal file
@@ -0,0 +1,265 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* PreToolUse Hook: GateGuard Fact-Forcing Gate
|
||||
*
|
||||
* Forces Claude to investigate before editing files or running commands.
|
||||
* Instead of asking "are you sure?" (which LLMs always answer "yes"),
|
||||
* this hook demands concrete facts: importers, public API, data schemas.
|
||||
*
|
||||
* The act of investigation creates awareness that self-evaluation never did.
|
||||
*
|
||||
* Gates:
|
||||
* - Edit/Write: list importers, affected API, verify data schemas, quote instruction
|
||||
* - Bash (destructive): list targets, rollback plan, quote instruction
|
||||
* - Bash (routine): quote current instruction (once per session)
|
||||
*
|
||||
* Compatible with run-with-flags.js via module.exports.run().
|
||||
* Cross-platform (Windows, macOS, Linux).
|
||||
*
|
||||
* Full package with config support: pip install gateguard-ai
|
||||
* Repo: https://github.com/zunoworks/gateguard
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Session state — scoped per session to avoid cross-session races.
|
||||
// Uses CLAUDE_SESSION_ID (set by Claude Code) or falls back to PID-based isolation.
|
||||
const STATE_DIR = process.env.GATEGUARD_STATE_DIR || path.join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.gateguard');
|
||||
const SESSION_ID = process.env.CLAUDE_SESSION_ID || process.env.ECC_SESSION_ID || `pid-${process.ppid || process.pid}`;
|
||||
const STATE_FILE = path.join(STATE_DIR, `state-${SESSION_ID.replace(/[^a-zA-Z0-9_-]/g, '_')}.json`);
|
||||
|
||||
// State expires after 30 minutes of inactivity
|
||||
const SESSION_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
|
||||
// Maximum checked entries to prevent unbounded growth
|
||||
const MAX_CHECKED_ENTRIES = 500;
|
||||
const MAX_SESSION_KEYS = 50;
|
||||
const ROUTINE_BASH_SESSION_KEY = '__bash_session__';
|
||||
|
||||
const DESTRUCTIVE_BASH = /\b(rm\s+-rf|git\s+reset\s+--hard|git\s+checkout\s+--|git\s+clean\s+-f|drop\s+table|delete\s+from|truncate|git\s+push\s+--force|dd\s+if=)\b/i;
|
||||
|
||||
// --- State management (per-session, atomic writes, bounded) ---
|
||||
|
||||
function loadState() {
|
||||
try {
|
||||
if (fs.existsSync(STATE_FILE)) {
|
||||
const state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
||||
const lastActive = state.last_active || 0;
|
||||
if (Date.now() - lastActive > SESSION_TIMEOUT_MS) {
|
||||
try { fs.unlinkSync(STATE_FILE); } catch (_) { /* ignore */ }
|
||||
return { checked: [], last_active: Date.now() };
|
||||
}
|
||||
return state;
|
||||
}
|
||||
} catch (_) { /* ignore */ }
|
||||
return { checked: [], last_active: Date.now() };
|
||||
}
|
||||
|
||||
function pruneCheckedEntries(checked) {
|
||||
if (checked.length <= MAX_CHECKED_ENTRIES) {
|
||||
return checked;
|
||||
}
|
||||
|
||||
const preserved = checked.includes(ROUTINE_BASH_SESSION_KEY) ? [ROUTINE_BASH_SESSION_KEY] : [];
|
||||
const sessionKeys = checked.filter(k => k.startsWith('__') && k !== ROUTINE_BASH_SESSION_KEY);
|
||||
const fileKeys = checked.filter(k => !k.startsWith('__'));
|
||||
const remainingSessionSlots = Math.max(MAX_SESSION_KEYS - preserved.length, 0);
|
||||
const cappedSession = sessionKeys.slice(-remainingSessionSlots);
|
||||
const remainingFileSlots = Math.max(MAX_CHECKED_ENTRIES - preserved.length - cappedSession.length, 0);
|
||||
const cappedFiles = fileKeys.slice(-remainingFileSlots);
|
||||
return [...preserved, ...cappedSession, ...cappedFiles];
|
||||
}
|
||||
|
||||
function saveState(state) {
|
||||
try {
|
||||
state.last_active = Date.now();
|
||||
state.checked = pruneCheckedEntries(state.checked);
|
||||
fs.mkdirSync(STATE_DIR, { recursive: true });
|
||||
// Atomic write: temp file + rename prevents partial reads
|
||||
const tmpFile = STATE_FILE + '.tmp.' + process.pid;
|
||||
fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), 'utf8');
|
||||
fs.renameSync(tmpFile, STATE_FILE);
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
function markChecked(key) {
|
||||
const state = loadState();
|
||||
if (!state.checked.includes(key)) {
|
||||
state.checked.push(key);
|
||||
saveState(state);
|
||||
}
|
||||
}
|
||||
|
||||
function isChecked(key) {
|
||||
const state = loadState();
|
||||
const found = state.checked.includes(key);
|
||||
saveState(state);
|
||||
return found;
|
||||
}
|
||||
|
||||
// Prune stale session files older than 1 hour
|
||||
(function pruneStaleFiles() {
|
||||
try {
|
||||
const files = fs.readdirSync(STATE_DIR);
|
||||
const now = Date.now();
|
||||
for (const f of files) {
|
||||
if (!f.startsWith('state-') || !f.endsWith('.json')) continue;
|
||||
const fp = path.join(STATE_DIR, f);
|
||||
const stat = fs.statSync(fp);
|
||||
if (now - stat.mtimeMs > SESSION_TIMEOUT_MS * 2) {
|
||||
fs.unlinkSync(fp);
|
||||
}
|
||||
}
|
||||
} catch (_) { /* ignore */ }
|
||||
})();
|
||||
|
||||
// --- Sanitize file path against injection ---
|
||||
|
||||
function sanitizePath(filePath) {
|
||||
// Strip control chars (including null), bidi overrides, and newlines
|
||||
return filePath.replace(/[\x00-\x1f\x7f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, ' ').trim().slice(0, 500);
|
||||
}
|
||||
|
||||
// --- Gate messages ---
|
||||
|
||||
function editGateMsg(filePath) {
|
||||
const safe = sanitizePath(filePath);
|
||||
return [
|
||||
'[Fact-Forcing Gate]',
|
||||
'',
|
||||
`Before editing ${safe}, present these facts:`,
|
||||
'',
|
||||
'1. List ALL files that import/require this file (use Grep)',
|
||||
'2. List the public functions/classes affected by this change',
|
||||
'3. If this file reads/writes data files, show field names, structure, and date format (use redacted or synthetic values, not raw production data)',
|
||||
'4. Quote the user\'s current instruction verbatim',
|
||||
'',
|
||||
'Present the facts, then retry the same operation.'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function writeGateMsg(filePath) {
|
||||
const safe = sanitizePath(filePath);
|
||||
return [
|
||||
'[Fact-Forcing Gate]',
|
||||
'',
|
||||
`Before creating ${safe}, present these facts:`,
|
||||
'',
|
||||
'1. Name the file(s) and line(s) that will call this new file',
|
||||
'2. Confirm no existing file serves the same purpose (use Glob)',
|
||||
'3. If this file reads/writes data files, show field names, structure, and date format (use redacted or synthetic values, not raw production data)',
|
||||
'4. Quote the user\'s current instruction verbatim',
|
||||
'',
|
||||
'Present the facts, then retry the same operation.'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function destructiveBashMsg() {
|
||||
return [
|
||||
'[Fact-Forcing Gate]',
|
||||
'',
|
||||
'Destructive command detected. Before running, present:',
|
||||
'',
|
||||
'1. List all files/data this command will modify or delete',
|
||||
'2. Write a one-line rollback procedure',
|
||||
'3. Quote the user\'s current instruction verbatim',
|
||||
'',
|
||||
'Present the facts, then retry the same operation.'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function routineBashMsg() {
|
||||
return [
|
||||
'[Fact-Forcing Gate]',
|
||||
'',
|
||||
'Quote the user\'s current instruction verbatim.',
|
||||
'Then retry the same operation.'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// --- Deny helper ---
|
||||
|
||||
function denyResult(reason) {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'deny',
|
||||
permissionDecisionReason: reason
|
||||
}
|
||||
}),
|
||||
exitCode: 0
|
||||
};
|
||||
}
|
||||
|
||||
// --- Core logic (exported for run-with-flags.js) ---
|
||||
|
||||
function run(rawInput) {
|
||||
let data;
|
||||
try {
|
||||
data = typeof rawInput === 'string' ? JSON.parse(rawInput) : rawInput;
|
||||
} catch (_) {
|
||||
return rawInput; // allow on parse error
|
||||
}
|
||||
|
||||
const rawToolName = data.tool_name || '';
|
||||
const toolInput = data.tool_input || {};
|
||||
// Normalize: case-insensitive matching via lookup map
|
||||
const TOOL_MAP = { 'edit': 'Edit', 'write': 'Write', 'multiedit': 'MultiEdit', 'bash': 'Bash' };
|
||||
const toolName = TOOL_MAP[rawToolName.toLowerCase()] || rawToolName;
|
||||
|
||||
if (toolName === 'Edit' || toolName === 'Write') {
|
||||
const filePath = toolInput.file_path || '';
|
||||
if (!filePath) {
|
||||
return rawInput; // allow
|
||||
}
|
||||
|
||||
if (!isChecked(filePath)) {
|
||||
markChecked(filePath);
|
||||
return denyResult(toolName === 'Edit' ? editGateMsg(filePath) : writeGateMsg(filePath));
|
||||
}
|
||||
|
||||
return rawInput; // allow
|
||||
}
|
||||
|
||||
if (toolName === 'MultiEdit') {
|
||||
const edits = toolInput.edits || [];
|
||||
for (const edit of edits) {
|
||||
const filePath = edit.file_path || '';
|
||||
if (filePath && !isChecked(filePath)) {
|
||||
markChecked(filePath);
|
||||
return denyResult(editGateMsg(filePath));
|
||||
}
|
||||
}
|
||||
return rawInput; // allow
|
||||
}
|
||||
|
||||
if (toolName === 'Bash') {
|
||||
const command = toolInput.command || '';
|
||||
|
||||
if (DESTRUCTIVE_BASH.test(command)) {
|
||||
// Gate destructive commands on first attempt; allow retry after facts presented
|
||||
const key = '__destructive__' + crypto.createHash('sha256').update(command).digest('hex').slice(0, 16);
|
||||
if (!isChecked(key)) {
|
||||
markChecked(key);
|
||||
return denyResult(destructiveBashMsg());
|
||||
}
|
||||
return rawInput; // allow retry after facts presented
|
||||
}
|
||||
|
||||
if (!isChecked(ROUTINE_BASH_SESSION_KEY)) {
|
||||
markChecked(ROUTINE_BASH_SESSION_KEY);
|
||||
return denyResult(routineBashMsg());
|
||||
}
|
||||
|
||||
return rawInput; // allow
|
||||
}
|
||||
|
||||
return rawInput; // allow
|
||||
}
|
||||
|
||||
module.exports = { run };
|
||||
@@ -55,7 +55,7 @@ function sanitizeParamValue(value, depth = 0) {
|
||||
return '[Truncated]';
|
||||
}
|
||||
|
||||
if (value == null) {
|
||||
if (value === null || value === undefined) {
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -359,44 +359,72 @@ function gitRepoRoot(cwd) {
|
||||
return runGit(['rev-parse', '--show-toplevel'], cwd);
|
||||
}
|
||||
|
||||
function repoRelativePath(repoRoot, filePath) {
|
||||
const absolute = path.isAbsolute(filePath)
|
||||
? path.resolve(filePath)
|
||||
: path.resolve(process.cwd(), filePath);
|
||||
const relative = path.relative(repoRoot, absolute);
|
||||
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
return null;
|
||||
const MAX_RELEVANT_PATCH_LINES = 6;
|
||||
|
||||
function candidateGitPaths(repoRoot, filePath) {
|
||||
const resolvedRepoRoot = path.resolve(repoRoot);
|
||||
const candidates = [];
|
||||
const pushCandidate = value => {
|
||||
const candidate = String(value || '').trim();
|
||||
if (!candidate || candidates.includes(candidate)) {
|
||||
return;
|
||||
}
|
||||
candidates.push(candidate);
|
||||
};
|
||||
|
||||
const absoluteCandidates = path.isAbsolute(filePath)
|
||||
? [path.resolve(filePath)]
|
||||
: [
|
||||
path.resolve(resolvedRepoRoot, filePath),
|
||||
path.resolve(process.cwd(), filePath),
|
||||
];
|
||||
|
||||
for (const absolute of absoluteCandidates) {
|
||||
const relative = path.relative(resolvedRepoRoot, absolute);
|
||||
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
pushCandidate(relative);
|
||||
pushCandidate(relative.split(path.sep).join('/'));
|
||||
pushCandidate(absolute);
|
||||
pushCandidate(absolute.split(path.sep).join('/'));
|
||||
}
|
||||
return relative.split(path.sep).join('/');
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function patchPreviewFromGitDiff(repoRoot, repoRelative) {
|
||||
const patch = runGit(
|
||||
['diff', '--no-ext-diff', '--no-color', '--unified=1', '--', repoRelative],
|
||||
repoRoot
|
||||
function patchPreviewFromGitDiff(repoRoot, pathCandidates) {
|
||||
for (const candidate of pathCandidates) {
|
||||
const patch = runGit(
|
||||
['diff', '--no-ext-diff', '--no-color', '--unified=1', '--', candidate],
|
||||
repoRoot
|
||||
);
|
||||
if (!patch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const relevant = patch
|
||||
.split(/\r?\n/)
|
||||
.filter(line =>
|
||||
line.startsWith('@@')
|
||||
|| (line.startsWith('+') && !line.startsWith('+++'))
|
||||
|| (line.startsWith('-') && !line.startsWith('---'))
|
||||
)
|
||||
.slice(0, MAX_RELEVANT_PATCH_LINES);
|
||||
|
||||
if (relevant.length > 0) {
|
||||
return relevant.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function trackedInGit(repoRoot, pathCandidates) {
|
||||
return pathCandidates.some(candidate =>
|
||||
runGit(['ls-files', '--error-unmatch', '--', candidate], repoRoot) !== null
|
||||
);
|
||||
if (!patch) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const relevant = patch
|
||||
.split(/\r?\n/)
|
||||
.filter(line =>
|
||||
line.startsWith('@@')
|
||||
|| (line.startsWith('+') && !line.startsWith('+++'))
|
||||
|| (line.startsWith('-') && !line.startsWith('---'))
|
||||
)
|
||||
.slice(0, 6);
|
||||
|
||||
if (relevant.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return relevant.join('\n');
|
||||
}
|
||||
|
||||
function trackedInGit(repoRoot, repoRelative) {
|
||||
return runGit(['ls-files', '--error-unmatch', '--', repoRelative], repoRoot) !== null;
|
||||
}
|
||||
|
||||
function enrichFileEventFromWorkingTree(toolName, event) {
|
||||
@@ -409,14 +437,14 @@ function enrichFileEventFromWorkingTree(toolName, event) {
|
||||
return event;
|
||||
}
|
||||
|
||||
const repoRelative = repoRelativePath(repoRoot, event.path);
|
||||
if (!repoRelative) {
|
||||
const pathCandidates = candidateGitPaths(repoRoot, event.path);
|
||||
if (pathCandidates.length === 0) {
|
||||
return event;
|
||||
}
|
||||
|
||||
const tool = String(toolName || '').trim().toLowerCase();
|
||||
const tracked = trackedInGit(repoRoot, repoRelative);
|
||||
const patchPreview = patchPreviewFromGitDiff(repoRoot, repoRelative) || event.patch_preview;
|
||||
const tracked = trackedInGit(repoRoot, pathCandidates);
|
||||
const patchPreview = patchPreviewFromGitDiff(repoRoot, pathCandidates) || event.patch_preview;
|
||||
const diffPreview = buildDiffPreviewFromPatchPreview(patchPreview) || event.diff_preview;
|
||||
|
||||
if (tool.includes('write')) {
|
||||
@@ -502,7 +530,7 @@ function summarizeInput(toolName, toolInput, filePaths) {
|
||||
if (toolInput && typeof toolInput === 'object') {
|
||||
const shallow = {};
|
||||
for (const [key, value] of Object.entries(toolInput)) {
|
||||
if (value == null) {
|
||||
if (value === null || value === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
@@ -517,7 +545,7 @@ function summarizeInput(toolName, toolInput, filePaths) {
|
||||
}
|
||||
|
||||
function summarizeOutput(toolOutput) {
|
||||
if (toolOutput == null) {
|
||||
if (toolOutput === null || toolOutput === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ Usage: install.sh [--target <${LEGACY_INSTALL_TARGETS.join('|')}>] [--dry-run] [
|
||||
install.sh [--dry-run] [--json] --config <path>
|
||||
|
||||
Targets:
|
||||
claude (default) - Install rules to ~/.claude/rules/
|
||||
claude (default) - Install ECC into ~/.claude/ (hooks, commands, agents, rules, skills)
|
||||
cursor - Install rules, hooks, and bundled Cursor configs to ./.cursor/
|
||||
antigravity - Install rules, workflows, skills, and agents to ./.agent/
|
||||
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
createFlatRuleOperations,
|
||||
createInstallTargetAdapter,
|
||||
createManagedOperation,
|
||||
isForeignPlatformPath,
|
||||
} = require('./helpers');
|
||||
|
||||
function toCursorRuleFileName(fileName, sourceRelativeFile) {
|
||||
if (path.basename(sourceRelativeFile).toLowerCase() === 'readme.md') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fileName.endsWith('.md')
|
||||
? `${fileName.slice(0, -3)}.mdc`
|
||||
: fileName;
|
||||
}
|
||||
|
||||
module.exports = createInstallTargetAdapter({
|
||||
id: 'cursor-project',
|
||||
target: 'cursor',
|
||||
@@ -17,6 +29,7 @@ module.exports = createInstallTargetAdapter({
|
||||
const modules = Array.isArray(input.modules)
|
||||
? input.modules
|
||||
: (input.module ? [input.module] : []);
|
||||
const seenDestinationPaths = new Set();
|
||||
const {
|
||||
repoRoot,
|
||||
projectRoot,
|
||||
@@ -28,23 +41,98 @@ module.exports = createInstallTargetAdapter({
|
||||
homeDir,
|
||||
};
|
||||
const targetRoot = adapter.resolveRoot(planningInput);
|
||||
|
||||
return modules.flatMap(module => {
|
||||
const entries = modules.flatMap((module, moduleIndex) => {
|
||||
const paths = Array.isArray(module.paths) ? module.paths : [];
|
||||
return paths
|
||||
.filter(p => !isForeignPlatformPath(p, adapter.target))
|
||||
.flatMap(sourceRelativePath => {
|
||||
if (sourceRelativePath === 'rules') {
|
||||
return createFlatRuleOperations({
|
||||
moduleId: module.id,
|
||||
repoRoot,
|
||||
sourceRelativePath,
|
||||
destinationDir: path.join(targetRoot, 'rules'),
|
||||
});
|
||||
}
|
||||
.map((sourceRelativePath, pathIndex) => ({
|
||||
module,
|
||||
sourceRelativePath,
|
||||
moduleIndex,
|
||||
pathIndex,
|
||||
}));
|
||||
}).sort((left, right) => {
|
||||
const getPriority = value => {
|
||||
if (value === 'rules') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return [adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput)];
|
||||
if (value === '.cursor') {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 2;
|
||||
};
|
||||
|
||||
const leftPriority = getPriority(left.sourceRelativePath);
|
||||
const rightPriority = getPriority(right.sourceRelativePath);
|
||||
if (leftPriority !== rightPriority) {
|
||||
return leftPriority - rightPriority;
|
||||
}
|
||||
|
||||
if (left.moduleIndex !== right.moduleIndex) {
|
||||
return left.moduleIndex - right.moduleIndex;
|
||||
}
|
||||
|
||||
return left.pathIndex - right.pathIndex;
|
||||
});
|
||||
|
||||
function takeUniqueOperations(operations) {
|
||||
return operations.filter(operation => {
|
||||
if (!operation || !operation.destinationPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (seenDestinationPaths.has(operation.destinationPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seenDestinationPaths.add(operation.destinationPath);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
return entries.flatMap(({ module, sourceRelativePath }) => {
|
||||
if (sourceRelativePath === 'rules') {
|
||||
return takeUniqueOperations(createFlatRuleOperations({
|
||||
moduleId: module.id,
|
||||
repoRoot,
|
||||
sourceRelativePath,
|
||||
destinationDir: path.join(targetRoot, 'rules'),
|
||||
destinationNameTransform: toCursorRuleFileName,
|
||||
}));
|
||||
}
|
||||
|
||||
if (sourceRelativePath === '.cursor') {
|
||||
const cursorRoot = path.join(repoRoot, '.cursor');
|
||||
if (!fs.existsSync(cursorRoot) || !fs.statSync(cursorRoot).isDirectory()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const childOperations = fs.readdirSync(cursorRoot, { withFileTypes: true })
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
.filter(entry => entry.name !== 'rules')
|
||||
.map(entry => createManagedOperation({
|
||||
moduleId: module.id,
|
||||
sourceRelativePath: path.join('.cursor', entry.name),
|
||||
destinationPath: path.join(targetRoot, entry.name),
|
||||
strategy: 'preserve-relative-path',
|
||||
}));
|
||||
|
||||
const ruleOperations = createFlatRuleOperations({
|
||||
moduleId: module.id,
|
||||
repoRoot,
|
||||
sourceRelativePath: '.cursor/rules',
|
||||
destinationDir: path.join(targetRoot, 'rules'),
|
||||
destinationNameTransform: toCursorRuleFileName,
|
||||
});
|
||||
|
||||
return takeUniqueOperations([...childOperations, ...ruleOperations]);
|
||||
}
|
||||
|
||||
return takeUniqueOperations([
|
||||
adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput),
|
||||
]);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -181,7 +181,13 @@ function createNamespacedFlatRuleOperations(adapter, moduleId, sourceRelativePat
|
||||
return operations;
|
||||
}
|
||||
|
||||
function createFlatRuleOperations({ moduleId, repoRoot, sourceRelativePath, destinationDir }) {
|
||||
function createFlatRuleOperations({
|
||||
moduleId,
|
||||
repoRoot,
|
||||
sourceRelativePath,
|
||||
destinationDir,
|
||||
destinationNameTransform,
|
||||
}) {
|
||||
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
|
||||
const sourceRoot = path.join(repoRoot || '', normalizedSourcePath);
|
||||
|
||||
@@ -201,19 +207,33 @@ function createFlatRuleOperations({ moduleId, repoRoot, sourceRelativePath, dest
|
||||
if (entry.isDirectory()) {
|
||||
const relativeFiles = listRelativeFiles(entryPath);
|
||||
for (const relativeFile of relativeFiles) {
|
||||
const flattenedFileName = `${namespace}-${normalizeRelativePath(relativeFile).replace(/\//g, '-')}`;
|
||||
const defaultFileName = `${namespace}-${normalizeRelativePath(relativeFile).replace(/\//g, '-')}`;
|
||||
const sourceRelativeFile = path.join(normalizedSourcePath, namespace, relativeFile);
|
||||
const flattenedFileName = typeof destinationNameTransform === 'function'
|
||||
? destinationNameTransform(defaultFileName, sourceRelativeFile)
|
||||
: defaultFileName;
|
||||
if (!flattenedFileName) {
|
||||
continue;
|
||||
}
|
||||
operations.push(createManagedOperation({
|
||||
moduleId,
|
||||
sourceRelativePath: path.join(normalizedSourcePath, namespace, relativeFile),
|
||||
sourceRelativePath: sourceRelativeFile,
|
||||
destinationPath: path.join(destinationDir, flattenedFileName),
|
||||
strategy: 'flatten-copy',
|
||||
}));
|
||||
}
|
||||
} else if (entry.isFile()) {
|
||||
const sourceRelativeFile = path.join(normalizedSourcePath, entry.name);
|
||||
const destinationFileName = typeof destinationNameTransform === 'function'
|
||||
? destinationNameTransform(entry.name, sourceRelativeFile)
|
||||
: entry.name;
|
||||
if (!destinationFileName) {
|
||||
continue;
|
||||
}
|
||||
operations.push(createManagedOperation({
|
||||
moduleId,
|
||||
sourceRelativePath: path.join(normalizedSourcePath, entry.name),
|
||||
destinationPath: path.join(destinationDir, entry.name),
|
||||
sourceRelativePath: sourceRelativeFile,
|
||||
destinationPath: path.join(destinationDir, destinationFileName),
|
||||
strategy: 'flatten-copy',
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -46,80 +46,6 @@ function replacePluginRootPlaceholders(value, pluginRoot) {
|
||||
return value;
|
||||
}
|
||||
|
||||
function buildLegacyHookSignature(entry, pluginRoot) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedEntry = replacePluginRootPlaceholders(entry, pluginRoot);
|
||||
|
||||
if (typeof normalizedEntry.matcher !== 'string' || !Array.isArray(normalizedEntry.hooks)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hookSignature = normalizedEntry.hooks.map(hook => JSON.stringify({
|
||||
type: hook && typeof hook === 'object' ? hook.type : undefined,
|
||||
command: hook && typeof hook === 'object' ? hook.command : undefined,
|
||||
timeout: hook && typeof hook === 'object' ? hook.timeout : undefined,
|
||||
async: hook && typeof hook === 'object' ? hook.async : undefined,
|
||||
}));
|
||||
|
||||
return JSON.stringify({
|
||||
matcher: normalizedEntry.matcher,
|
||||
hooks: hookSignature,
|
||||
});
|
||||
}
|
||||
|
||||
function getHookEntryAliases(entry, pluginRoot) {
|
||||
const aliases = [];
|
||||
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
return aliases;
|
||||
}
|
||||
|
||||
const normalizedEntry = replacePluginRootPlaceholders(entry, pluginRoot);
|
||||
|
||||
if (typeof normalizedEntry.id === 'string' && normalizedEntry.id.trim().length > 0) {
|
||||
aliases.push(`id:${normalizedEntry.id.trim()}`);
|
||||
}
|
||||
|
||||
const legacySignature = buildLegacyHookSignature(normalizedEntry, pluginRoot);
|
||||
if (legacySignature) {
|
||||
aliases.push(`legacy:${legacySignature}`);
|
||||
}
|
||||
|
||||
aliases.push(`json:${JSON.stringify(normalizedEntry)}`);
|
||||
|
||||
return aliases;
|
||||
}
|
||||
|
||||
function mergeHookEntries(existingEntries, incomingEntries, pluginRoot) {
|
||||
const mergedEntries = [];
|
||||
const seenEntries = new Set();
|
||||
|
||||
for (const entry of [...existingEntries, ...incomingEntries]) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ('id' in entry && typeof entry.id !== 'string') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const aliases = getHookEntryAliases(entry, pluginRoot);
|
||||
if (aliases.some(alias => seenEntries.has(alias))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const alias of aliases) {
|
||||
seenEntries.add(alias);
|
||||
}
|
||||
mergedEntries.push(replacePluginRootPlaceholders(entry, pluginRoot));
|
||||
}
|
||||
|
||||
return mergedEntries;
|
||||
}
|
||||
|
||||
function findHooksSourcePath(plan, hooksDestinationPath) {
|
||||
const operation = plan.operations.find(item => item.destinationPath === hooksDestinationPath);
|
||||
return operation ? operation.sourcePath : null;
|
||||
@@ -168,7 +94,7 @@ function buildFilteredMcpWrites(plan) {
|
||||
return writes;
|
||||
}
|
||||
|
||||
function buildMergedSettings(plan) {
|
||||
function buildResolvedClaudeHooks(plan) {
|
||||
if (!plan.adapter || plan.adapter.target !== 'claude') {
|
||||
return null;
|
||||
}
|
||||
@@ -181,46 +107,22 @@ function buildMergedSettings(plan) {
|
||||
}
|
||||
|
||||
const hooksConfig = readJsonObject(hooksSourcePath, 'hooks config');
|
||||
const incomingHooks = replacePluginRootPlaceholders(hooksConfig.hooks, pluginRoot);
|
||||
if (!incomingHooks || typeof incomingHooks !== 'object' || Array.isArray(incomingHooks)) {
|
||||
const resolvedHooks = replacePluginRootPlaceholders(hooksConfig.hooks, pluginRoot);
|
||||
if (!resolvedHooks || typeof resolvedHooks !== 'object' || Array.isArray(resolvedHooks)) {
|
||||
throw new Error(`Invalid hooks config at ${hooksSourcePath}: expected "hooks" to be a JSON object`);
|
||||
}
|
||||
|
||||
const settingsPath = path.join(plan.targetRoot, 'settings.json');
|
||||
let settings = {};
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
settings = readJsonObject(settingsPath, 'existing settings');
|
||||
}
|
||||
|
||||
const existingHooks = settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks)
|
||||
? settings.hooks
|
||||
: {};
|
||||
const mergedHooks = { ...existingHooks };
|
||||
|
||||
for (const [eventName, incomingEntries] of Object.entries(incomingHooks)) {
|
||||
const currentEntries = Array.isArray(existingHooks[eventName]) ? existingHooks[eventName] : [];
|
||||
const nextEntries = Array.isArray(incomingEntries) ? incomingEntries : [];
|
||||
mergedHooks[eventName] = mergeHookEntries(currentEntries, nextEntries, pluginRoot);
|
||||
}
|
||||
|
||||
const mergedSettings = {
|
||||
...settings,
|
||||
hooks: mergedHooks,
|
||||
};
|
||||
|
||||
return {
|
||||
settingsPath,
|
||||
mergedSettings,
|
||||
hooksDestinationPath,
|
||||
resolvedHooksConfig: {
|
||||
...hooksConfig,
|
||||
hooks: incomingHooks,
|
||||
hooks: resolvedHooks,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function applyInstallPlan(plan) {
|
||||
const mergedSettingsPlan = buildMergedSettings(plan);
|
||||
const resolvedClaudeHooksPlan = buildResolvedClaudeHooks(plan);
|
||||
const filteredMcpWrites = buildFilteredMcpWrites(plan);
|
||||
|
||||
for (const operation of plan.operations) {
|
||||
@@ -228,17 +130,11 @@ function applyInstallPlan(plan) {
|
||||
fs.copyFileSync(operation.sourcePath, operation.destinationPath);
|
||||
}
|
||||
|
||||
if (mergedSettingsPlan) {
|
||||
fs.mkdirSync(path.dirname(mergedSettingsPlan.hooksDestinationPath), { recursive: true });
|
||||
if (resolvedClaudeHooksPlan) {
|
||||
fs.mkdirSync(path.dirname(resolvedClaudeHooksPlan.hooksDestinationPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
mergedSettingsPlan.hooksDestinationPath,
|
||||
JSON.stringify(mergedSettingsPlan.resolvedHooksConfig, null, 2) + '\n',
|
||||
'utf8'
|
||||
);
|
||||
fs.mkdirSync(path.dirname(mergedSettingsPlan.settingsPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
mergedSettingsPlan.settingsPath,
|
||||
JSON.stringify(mergedSettingsPlan.mergedSettings, null, 2) + '\n',
|
||||
resolvedClaudeHooksPlan.hooksDestinationPath,
|
||||
JSON.stringify(resolvedClaudeHooksPlan.resolvedHooksConfig, null, 2) + '\n',
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,9 +6,22 @@ set -euo pipefail
|
||||
|
||||
VERSION="${1:-}"
|
||||
ROOT_PACKAGE_JSON="package.json"
|
||||
PACKAGE_LOCK_JSON="package-lock.json"
|
||||
ROOT_AGENTS_MD="AGENTS.md"
|
||||
TR_AGENTS_MD="docs/tr/AGENTS.md"
|
||||
ZH_CN_AGENTS_MD="docs/zh-CN/AGENTS.md"
|
||||
AGENT_YAML="agent.yaml"
|
||||
VERSION_FILE="VERSION"
|
||||
PLUGIN_JSON=".claude-plugin/plugin.json"
|
||||
MARKETPLACE_JSON=".claude-plugin/marketplace.json"
|
||||
CODEX_MARKETPLACE_JSON=".agents/plugins/marketplace.json"
|
||||
CODEX_PLUGIN_JSON=".codex-plugin/plugin.json"
|
||||
OPENCODE_PACKAGE_JSON=".opencode/package.json"
|
||||
OPENCODE_PACKAGE_LOCK_JSON=".opencode/package-lock.json"
|
||||
OPENCODE_ECC_HOOKS_PLUGIN=".opencode/plugins/ecc-hooks.ts"
|
||||
README_FILE="README.md"
|
||||
ZH_CN_README_FILE="docs/zh-CN/README.md"
|
||||
SELECTIVE_INSTALL_ARCHITECTURE_DOC="docs/SELECTIVE-INSTALL-ARCHITECTURE.md"
|
||||
|
||||
# Function to show usage
|
||||
usage() {
|
||||
@@ -36,14 +49,14 @@ if [[ "$CURRENT_BRANCH" != "main" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check working tree is clean
|
||||
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||
# Check working tree is clean, including untracked files
|
||||
if [[ -n "$(git status --porcelain --untracked-files=all)" ]]; then
|
||||
echo "Error: Working tree is not clean. Commit or stash changes first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify versioned manifests exist
|
||||
for FILE in "$ROOT_PACKAGE_JSON" "$PLUGIN_JSON" "$MARKETPLACE_JSON" "$OPENCODE_PACKAGE_JSON"; do
|
||||
for FILE in "$ROOT_PACKAGE_JSON" "$PACKAGE_LOCK_JSON" "$ROOT_AGENTS_MD" "$TR_AGENTS_MD" "$ZH_CN_AGENTS_MD" "$AGENT_YAML" "$VERSION_FILE" "$PLUGIN_JSON" "$MARKETPLACE_JSON" "$CODEX_MARKETPLACE_JSON" "$CODEX_PLUGIN_JSON" "$OPENCODE_PACKAGE_JSON" "$OPENCODE_PACKAGE_LOCK_JSON" "$OPENCODE_ECC_HOOKS_PLUGIN" "$README_FILE" "$ZH_CN_README_FILE" "$SELECTIVE_INSTALL_ARCHITECTURE_DOC"; do
|
||||
if [[ ! -f "$FILE" ]]; then
|
||||
echo "Error: $FILE not found"
|
||||
exit 1
|
||||
@@ -58,13 +71,6 @@ if [[ -z "$OLD_VERSION" ]]; then
|
||||
fi
|
||||
echo "Bumping version: $OLD_VERSION -> $VERSION"
|
||||
|
||||
# Build and verify the packaged OpenCode payload before mutating any manifest
|
||||
# versions or creating a tag. This keeps a broken npm artifact from being
|
||||
# released via the manual script path.
|
||||
echo "Verifying OpenCode build and npm pack payload..."
|
||||
node scripts/build-opencode.js
|
||||
node tests/scripts/build-opencode.test.js
|
||||
|
||||
update_version() {
|
||||
local file="$1"
|
||||
local pattern="$2"
|
||||
@@ -75,14 +81,189 @@ update_version() {
|
||||
fi
|
||||
}
|
||||
|
||||
update_package_lock_version() {
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const file = process.argv[1];
|
||||
const version = process.argv[2];
|
||||
const lock = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
if (!lock || typeof lock !== "object") {
|
||||
console.error(`Error: ${file} does not contain a JSON object`);
|
||||
process.exit(1);
|
||||
}
|
||||
lock.version = version;
|
||||
if (!lock.packages || typeof lock.packages !== "object" || Array.isArray(lock.packages)) {
|
||||
console.error(`Error: ${file} is missing lock.packages`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!lock.packages[""] || typeof lock.packages[""] !== "object" || Array.isArray(lock.packages[""])) {
|
||||
console.error(`Error: ${file} is missing lock.packages[\"\"]`);
|
||||
process.exit(1);
|
||||
}
|
||||
lock.packages[""].version = version;
|
||||
fs.writeFileSync(file, `${JSON.stringify(lock, null, 2)}\n`);
|
||||
' "$1" "$VERSION"
|
||||
}
|
||||
|
||||
update_readme_version_row() {
|
||||
local file="$1"
|
||||
local label="$2"
|
||||
local first_col="$3"
|
||||
local second_col="$4"
|
||||
local third_col="$5"
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const file = process.argv[1];
|
||||
const version = process.argv[2];
|
||||
const label = process.argv[3];
|
||||
const firstCol = process.argv[4];
|
||||
const secondCol = process.argv[5];
|
||||
const thirdCol = process.argv[6];
|
||||
const escape = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const current = fs.readFileSync(file, "utf8");
|
||||
const updated = current.replace(
|
||||
new RegExp(
|
||||
`^\\| \\*\\*${escape(label)}\\*\\* \\| ${escape(firstCol)} \\| ${escape(secondCol)} \\| ${escape(thirdCol)} \\| [0-9]+\\.[0-9]+\\.[0-9]+ \\|$`,
|
||||
"m"
|
||||
),
|
||||
`| **${label}** | ${firstCol} | ${secondCol} | ${thirdCol} | ${version} |`
|
||||
);
|
||||
if (updated === current) {
|
||||
console.error(`Error: could not update README version row in ${file}`);
|
||||
process.exit(1);
|
||||
}
|
||||
fs.writeFileSync(file, updated);
|
||||
' "$file" "$VERSION" "$label" "$first_col" "$second_col" "$third_col"
|
||||
}
|
||||
|
||||
update_selective_install_repo_version() {
|
||||
local file="$1"
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const file = process.argv[1];
|
||||
const version = process.argv[2];
|
||||
const current = fs.readFileSync(file, "utf8");
|
||||
const updated = current.replace(
|
||||
/("repoVersion":\s*")[0-9][0-9.]*(")/,
|
||||
`$1${version}$2`
|
||||
);
|
||||
if (updated === current) {
|
||||
console.error(`Error: could not update repoVersion example in ${file}`);
|
||||
process.exit(1);
|
||||
}
|
||||
fs.writeFileSync(file, updated);
|
||||
' "$file" "$VERSION"
|
||||
}
|
||||
|
||||
update_agents_version() {
|
||||
local file="$1"
|
||||
local label="$2"
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const file = process.argv[1];
|
||||
const version = process.argv[2];
|
||||
const label = process.argv[3];
|
||||
const current = fs.readFileSync(file, "utf8");
|
||||
const updated = current.replace(
|
||||
new RegExp(`^\\*\\*${label}:\\*\\* [0-9][0-9.]*$`, "m"),
|
||||
`**${label}:** ${version}`
|
||||
);
|
||||
if (updated === current) {
|
||||
console.error(`Error: could not update AGENTS version line in ${file}`);
|
||||
process.exit(1);
|
||||
}
|
||||
fs.writeFileSync(file, updated);
|
||||
' "$file" "$VERSION" "$label"
|
||||
}
|
||||
|
||||
update_agent_yaml_version() {
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const file = process.argv[1];
|
||||
const version = process.argv[2];
|
||||
const current = fs.readFileSync(file, "utf8");
|
||||
const updated = current.replace(
|
||||
/^version:\s*[0-9][0-9.]*$/m,
|
||||
`version: ${version}`
|
||||
);
|
||||
if (updated === current) {
|
||||
console.error(`Error: could not update agent.yaml version line in ${file}`);
|
||||
process.exit(1);
|
||||
}
|
||||
fs.writeFileSync(file, updated);
|
||||
' "$AGENT_YAML" "$VERSION"
|
||||
}
|
||||
|
||||
update_version_file() {
|
||||
printf '%s\n' "$VERSION" > "$VERSION_FILE"
|
||||
}
|
||||
|
||||
update_codex_marketplace_version() {
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const file = process.argv[1];
|
||||
const version = process.argv[2];
|
||||
const marketplace = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
if (!marketplace || typeof marketplace !== "object" || !Array.isArray(marketplace.plugins)) {
|
||||
console.error(`Error: ${file} does not contain a marketplace plugins array`);
|
||||
process.exit(1);
|
||||
}
|
||||
const plugin = marketplace.plugins.find(entry => entry && entry.name === "ecc");
|
||||
if (!plugin || typeof plugin !== "object") {
|
||||
console.error(`Error: could not find ecc plugin entry in ${file}`);
|
||||
process.exit(1);
|
||||
}
|
||||
plugin.version = version;
|
||||
fs.writeFileSync(file, `${JSON.stringify(marketplace, null, 2)}\n`);
|
||||
' "$CODEX_MARKETPLACE_JSON" "$VERSION"
|
||||
}
|
||||
|
||||
update_opencode_hook_banner_version() {
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const file = process.argv[1];
|
||||
const version = process.argv[2];
|
||||
const current = fs.readFileSync(file, "utf8");
|
||||
const updated = current.replace(
|
||||
/(## Active Plugin: Everything Claude Code v)[0-9]+\.[0-9]+\.[0-9]+/,
|
||||
`$1${version}`
|
||||
);
|
||||
if (updated === current) {
|
||||
console.error(`Error: could not update OpenCode hook banner version in ${file}`);
|
||||
process.exit(1);
|
||||
}
|
||||
fs.writeFileSync(file, updated);
|
||||
' "$OPENCODE_ECC_HOOKS_PLUGIN" "$VERSION"
|
||||
}
|
||||
|
||||
# Update all shipped package/plugin manifests
|
||||
update_version "$ROOT_PACKAGE_JSON" "s|\"version\": *\"[^\"]*\"|\"version\": \"$VERSION\"|"
|
||||
update_package_lock_version "$PACKAGE_LOCK_JSON"
|
||||
update_agents_version "$ROOT_AGENTS_MD" "Version"
|
||||
update_agents_version "$TR_AGENTS_MD" "Sürüm"
|
||||
update_agents_version "$ZH_CN_AGENTS_MD" "版本"
|
||||
update_agent_yaml_version
|
||||
update_version_file
|
||||
update_version "$PLUGIN_JSON" "s|\"version\": *\"[^\"]*\"|\"version\": \"$VERSION\"|"
|
||||
update_version "$MARKETPLACE_JSON" "0,/\"version\": *\"[^\"]*\"/s|\"version\": *\"[^\"]*\"|\"version\": \"$VERSION\"|"
|
||||
update_codex_marketplace_version
|
||||
update_version "$CODEX_PLUGIN_JSON" "s|\"version\": *\"[^\"]*\"|\"version\": \"$VERSION\"|"
|
||||
update_version "$OPENCODE_PACKAGE_JSON" "s|\"version\": *\"[^\"]*\"|\"version\": \"$VERSION\"|"
|
||||
update_package_lock_version "$OPENCODE_PACKAGE_LOCK_JSON"
|
||||
update_opencode_hook_banner_version
|
||||
update_readme_version_row "$README_FILE" "Version" "Plugin" "Plugin" "Reference config"
|
||||
update_readme_version_row "$ZH_CN_README_FILE" "版本" "插件" "插件" "参考配置"
|
||||
update_selective_install_repo_version "$SELECTIVE_INSTALL_ARCHITECTURE_DOC"
|
||||
|
||||
# Verify the bumped release surface is still internally consistent before
|
||||
# writing a release commit, tag, or push.
|
||||
echo "Verifying OpenCode build and npm pack payload..."
|
||||
node scripts/build-opencode.js
|
||||
node tests/scripts/build-opencode.test.js
|
||||
node tests/plugin-manifest.test.js
|
||||
|
||||
# Stage, commit, tag, and push
|
||||
git add "$ROOT_PACKAGE_JSON" "$PLUGIN_JSON" "$MARKETPLACE_JSON" "$OPENCODE_PACKAGE_JSON"
|
||||
git add "$ROOT_PACKAGE_JSON" "$PACKAGE_LOCK_JSON" "$ROOT_AGENTS_MD" "$TR_AGENTS_MD" "$ZH_CN_AGENTS_MD" "$AGENT_YAML" "$VERSION_FILE" "$PLUGIN_JSON" "$MARKETPLACE_JSON" "$CODEX_MARKETPLACE_JSON" "$CODEX_PLUGIN_JSON" "$OPENCODE_PACKAGE_JSON" "$OPENCODE_PACKAGE_LOCK_JSON" "$OPENCODE_ECC_HOOKS_PLUGIN" "$README_FILE" "$ZH_CN_README_FILE" "$SELECTIVE_INSTALL_ARCHITECTURE_DOC"
|
||||
git commit -m "chore: bump plugin version to $VERSION"
|
||||
git tag "v$VERSION"
|
||||
git push origin main "v$VERSION"
|
||||
|
||||
146
skills/accessibility/SKILL.md
Normal file
146
skills/accessibility/SKILL.md
Normal file
@@ -0,0 +1,146 @@
|
||||
---
|
||||
name: accessibility
|
||||
description: Design, implement, and audit inclusive digital products using WCAG 2.2 Level AA
|
||||
standards. Use this skill to generate semantic ARIA for Web and accessibility traits for Web and Native platforms (iOS/Android).
|
||||
origin: ECC
|
||||
---
|
||||
|
||||
# Accessibility (WCAG 2.2)
|
||||
|
||||
This skill ensures that digital interfaces are Perceivable, Operable, Understandable, and Robust (POUR) for all users, including those using screen readers, switch controls, or keyboard navigation. It focuses on the technical implementation of WCAG 2.2 success criteria.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Defining UI component specifications for Web, iOS, or Android.
|
||||
- Auditing existing code for accessibility barriers or compliance gaps.
|
||||
- Implementing new WCAG 2.2 standards like Target Size (Minimum) and Focus Appearance.
|
||||
- Mapping high-level design requirements to technical attributes (ARIA roles, traits, hints).
|
||||
|
||||
## Core Concepts
|
||||
|
||||
- **POUR Principles**: The foundation of WCAG (Perceivable, Operable, Understandable, Robust).
|
||||
- **Semantic Mapping**: Using native elements over generic containers to provide built-in accessibility.
|
||||
- **Accessibility Tree**: The representation of the UI that assistive technologies actually "read."
|
||||
- **Focus Management**: Controlling the order and visibility of the keyboard/screen reader cursor.
|
||||
- **Labeling & Hints**: Providing context through `aria-label`, `accessibilityLabel`, and `contentDescription`.
|
||||
|
||||
## How It Works
|
||||
|
||||
### Step 1: Identify the Component Role
|
||||
|
||||
Determine the functional purpose (e.g., Is this a button, a link, or a tab?). Use the most semantic native element available before resorting to custom roles.
|
||||
|
||||
### Step 2: Define Perceivable Attributes
|
||||
|
||||
- Ensure text contrast meets **4.5:1** (normal) or **3:1** (large/UI).
|
||||
- Add text alternatives for non-text content (images, icons).
|
||||
- Implement responsive reflow (up to 400% zoom without loss of function).
|
||||
|
||||
### Step 3: Implement Operable Controls
|
||||
|
||||
- Ensure a minimum **24x24 CSS pixel** target size (WCAG 2.2 SC 2.5.8).
|
||||
- Verify all interactive elements are reachable via keyboard and have a visible focus indicator (SC 2.4.11).
|
||||
- Provide single-pointer alternatives for dragging movements.
|
||||
|
||||
### Step 4: Ensure Understandable Logic
|
||||
|
||||
- Use consistent navigation patterns.
|
||||
- Provide descriptive error messages and suggestions for correction (SC 3.3.3).
|
||||
- Implement "Redundant Entry" (SC 3.3.7) to prevent asking for the same data twice.
|
||||
|
||||
### Step 5: Verify Robust Compatibility
|
||||
|
||||
- Use correct `Name, Role, Value` patterns.
|
||||
- Implement `aria-live` or live regions for dynamic status updates.
|
||||
|
||||
## Accessibility Architecture Diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
UI["UI Component"] --> Platform{Platform?}
|
||||
Platform -->|Web| ARIA["WAI-ARIA + HTML5"]
|
||||
Platform -->|iOS| SwiftUI["Accessibility Traits + Labels"]
|
||||
Platform -->|Android| Compose["Semantics + ContentDesc"]
|
||||
|
||||
ARIA --> AT["Assistive Technology (Screen Readers, Switches)"]
|
||||
SwiftUI --> AT
|
||||
Compose --> AT
|
||||
```
|
||||
|
||||
## Cross-Platform Mapping
|
||||
|
||||
| Feature | Web (HTML/ARIA) | iOS (SwiftUI) | Android (Compose) |
|
||||
| :----------------- | :----------------------- | :----------------------------------- | :---------------------------------------------------------- |
|
||||
| **Primary Label** | `aria-label` / `<label>` | `.accessibilityLabel()` | `contentDescription` |
|
||||
| **Secondary Hint** | `aria-describedby` | `.accessibilityHint()` | `Modifier.semantics { stateDescription = ... }` |
|
||||
| **Action Role** | `role="button"` | `.accessibilityAddTraits(.isButton)` | `Modifier.semantics { role = Role.Button }` |
|
||||
| **Live Updates** | `aria-live="polite"` | `.accessibilityLiveRegion(.polite)` | `Modifier.semantics { liveRegion = LiveRegionMode.Polite }` |
|
||||
|
||||
## Examples
|
||||
|
||||
### Web: Accessible Search
|
||||
|
||||
```html
|
||||
<form role="search">
|
||||
<label for="search-input" class="sr-only">Search products</label>
|
||||
<input type="search" id="search-input" placeholder="Search..." />
|
||||
<button type="submit" aria-label="Submit Search">
|
||||
<svg aria-hidden="true">...</svg>
|
||||
</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
### iOS: Accessible Action Button
|
||||
|
||||
```swift
|
||||
Button(action: deleteItem) {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
.accessibilityLabel("Delete item")
|
||||
.accessibilityHint("Permanently removes this item from your list")
|
||||
.accessibilityAddTraits(.isButton)
|
||||
```
|
||||
|
||||
### Android: Accessible Toggle
|
||||
|
||||
```kotlin
|
||||
Switch(
|
||||
checked = isEnabled,
|
||||
onCheckedChange = { onToggle() },
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription = "Enable notifications"
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
- **Div-Buttons**: Using a `<div>` or `<span>` for a click event without adding a role and keyboard support.
|
||||
- **Color-Only Meaning**: Indicating an error or status _only_ with a color change (e.g., turning a border red).
|
||||
- **Uncontained Modal Focus**: Modals that don't trap focus, allowing keyboard users to navigate background content while the modal is open. Focus must be contained _and_ escapable via the `Escape` key or an explicit close button (WCAG SC 2.1.2).
|
||||
- **Redundant Alt Text**: Using "Image of..." or "Picture of..." in alt text (screen readers already announce the role "Image").
|
||||
|
||||
## Best Practices Checklist
|
||||
|
||||
- [ ] Interactive elements meet the **24x24px** (Web) or **44x44pt** (Native) target size.
|
||||
- [ ] Focus indicators are clearly visible and high-contrast.
|
||||
- [ ] Modals **contain focus** while open, and release it cleanly on close (`Escape` key or close button).
|
||||
- [ ] Dropdowns and menus restore focus to the trigger element on close.
|
||||
- [ ] Forms provide text-based error suggestions.
|
||||
- [ ] All icon-only buttons have a descriptive text label.
|
||||
- [ ] Content reflows properly when text is scaled.
|
||||
|
||||
## References
|
||||
|
||||
- [WCAG 2.2 Guidelines](https://www.w3.org/TR/WCAG22/)
|
||||
- [WAI-ARIA Authoring Practices](https://www.w3.org/TR/wai-aria-practices/)
|
||||
- [iOS Accessibility Programming Guide](https://developer.apple.com/documentation/accessibility)
|
||||
- [iOS Human Interface Guidelines - Accessibility](https://developer.apple.com/design/human-interface-guidelines/accessibility)
|
||||
- [Android Accessibility Developer Guide](https://developer.android.com/guide/topics/ui/accessibility)
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `frontend-patterns`
|
||||
- `frontend-design`
|
||||
- `liquid-glass-design`
|
||||
- `swiftui-patterns`
|
||||
120
skills/gateguard/SKILL.md
Normal file
120
skills/gateguard/SKILL.md
Normal file
@@ -0,0 +1,120 @@
|
||||
---
|
||||
name: gateguard
|
||||
description: Fact-forcing gate that blocks Edit/Write/Bash (including MultiEdit) and demands concrete investigation (importers, data schemas, user instruction) before allowing the action. Measurably improves output quality by +2.25 points vs ungated agents.
|
||||
origin: community
|
||||
---
|
||||
|
||||
# GateGuard — Fact-Forcing Pre-Action Gate
|
||||
|
||||
A PreToolUse hook that forces Claude to investigate before editing. Instead of self-evaluation ("are you sure?"), it demands concrete facts. The act of investigation creates awareness that self-evaluation never did.
|
||||
|
||||
## When to Activate
|
||||
|
||||
- Working on any codebase where file edits affect multiple modules
|
||||
- Projects with data files that have specific schemas or date formats
|
||||
- Teams where AI-generated code must match existing patterns
|
||||
- Any workflow where Claude tends to guess instead of investigating
|
||||
|
||||
## Core Concept
|
||||
|
||||
LLM self-evaluation doesn't work. Ask "did you violate any policies?" and the answer is always "no." This is verified experimentally.
|
||||
|
||||
But asking "list every file that imports this module" forces the LLM to run Grep and Read. The investigation itself creates context that changes the output.
|
||||
|
||||
**Three-stage gate:**
|
||||
|
||||
```
|
||||
1. DENY — block the first Edit/Write/Bash attempt
|
||||
2. FORCE — tell the model exactly which facts to gather
|
||||
3. ALLOW — permit retry after facts are presented
|
||||
```
|
||||
|
||||
No competitor does all three. Most stop at deny.
|
||||
|
||||
## Evidence
|
||||
|
||||
Two independent A/B tests, identical agents, same task:
|
||||
|
||||
| Task | Gated | Ungated | Gap |
|
||||
| --- | --- | --- | --- |
|
||||
| Analytics module | 8.0/10 | 6.5/10 | +1.5 |
|
||||
| Webhook validator | 10.0/10 | 7.0/10 | +3.0 |
|
||||
| **Average** | **9.0** | **6.75** | **+2.25** |
|
||||
|
||||
Both agents produce code that runs and passes tests. The difference is design depth.
|
||||
|
||||
## Gate Types
|
||||
|
||||
### Edit / MultiEdit Gate (first edit per file)
|
||||
|
||||
MultiEdit is handled identically — each file in the batch is gated individually.
|
||||
|
||||
```
|
||||
Before editing {file_path}, present these facts:
|
||||
|
||||
1. List ALL files that import/require this file (use Grep)
|
||||
2. List the public functions/classes affected by this change
|
||||
3. If this file reads/writes data files, show field names, structure,
|
||||
and date format (use redacted or synthetic values, not raw production data)
|
||||
4. Quote the user's current instruction verbatim
|
||||
```
|
||||
|
||||
### Write Gate (first new file creation)
|
||||
|
||||
```
|
||||
Before creating {file_path}, present these facts:
|
||||
|
||||
1. Name the file(s) and line(s) that will call this new file
|
||||
2. Confirm no existing file serves the same purpose (use Glob)
|
||||
3. If this file reads/writes data files, show field names, structure,
|
||||
and date format (use redacted or synthetic values, not raw production data)
|
||||
4. Quote the user's current instruction verbatim
|
||||
```
|
||||
|
||||
### Destructive Bash Gate (every destructive command)
|
||||
|
||||
Triggers on: `rm -rf`, `git reset --hard`, `git push --force`, `drop table`, etc.
|
||||
|
||||
```
|
||||
1. List all files/data this command will modify or delete
|
||||
2. Write a one-line rollback procedure
|
||||
3. Quote the user's current instruction verbatim
|
||||
```
|
||||
|
||||
### Routine Bash Gate (once per session)
|
||||
|
||||
```
|
||||
Quote the user's current instruction verbatim.
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Option A: Use the ECC hook (zero install)
|
||||
|
||||
The hook at `scripts/hooks/gateguard-fact-force.js` is included in this plugin. Enable it via hooks.json.
|
||||
|
||||
### Option B: Full package with config
|
||||
|
||||
```bash
|
||||
pip install gateguard-ai
|
||||
gateguard init
|
||||
```
|
||||
|
||||
This adds `.gateguard.yml` for per-project configuration (custom messages, ignore paths, gate toggles).
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- **Don't use self-evaluation instead.** "Are you sure?" always gets "yes." This is experimentally verified.
|
||||
- **Don't skip the data schema check.** Both A/B test agents assumed ISO-8601 dates when real data used `%Y/%m/%d %H:%M`. Checking data structure (with redacted values) prevents this entire class of bugs.
|
||||
- **Don't gate every single Bash command.** Routine bash gates once per session. Destructive bash gates every time. This balance avoids slowdown while catching real risks.
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Let the gate fire naturally. Don't try to pre-answer the gate questions — the investigation itself is what improves quality.
|
||||
- Customize gate messages for your domain. If your project has specific conventions, add them to the gate prompts.
|
||||
- Use `.gateguard.yml` to ignore paths like `.venv/`, `node_modules/`, `.git/`.
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `safety-guard` — Runtime safety checks (complementary, not overlapping)
|
||||
- `code-reviewer` — Post-edit review (GateGuard is pre-edit investigation)
|
||||
@@ -33,7 +33,9 @@ def _check_temporal_order(
|
||||
) -> str | None:
|
||||
"""Check before_step/after_step constraints. Returns failure reason or None."""
|
||||
if step.detector.after_step is not None:
|
||||
after_events = resolved.get(step.detector.after_step, [])
|
||||
after_events = resolved.get(step.detector.after_step)
|
||||
if after_events is None:
|
||||
after_events = classified.get(step.detector.after_step, [])
|
||||
if not after_events:
|
||||
return f"after_step '{step.detector.after_step}' not yet detected"
|
||||
latest_after = max(e.timestamp for e in after_events)
|
||||
|
||||
@@ -6,7 +6,7 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
from scripts.grader import ComplianceResult, StepResult, grade
|
||||
from scripts.parser import parse_spec, parse_trace
|
||||
from scripts.parser import ComplianceSpec, Detector, ObservationEvent, Step, parse_spec, parse_trace
|
||||
|
||||
FIXTURES = Path(__file__).parent.parent / "fixtures"
|
||||
|
||||
@@ -135,3 +135,63 @@ class TestGradeEdgeCases:
|
||||
def test_spec_id_in_result(self, mock_cls, tdd_spec, compliant_trace) -> None:
|
||||
result = grade(tdd_spec, compliant_trace)
|
||||
assert result.spec_id == "tdd-workflow"
|
||||
|
||||
@patch("scripts.grader.classify_events")
|
||||
def test_after_step_can_reference_later_declared_spec_step(self, mock_cls) -> None:
|
||||
spec = ComplianceSpec(
|
||||
id="out-of-order-after-step",
|
||||
name="Out of order after_step",
|
||||
source_rule="rules/common/testing.md",
|
||||
version="1.0",
|
||||
steps=(
|
||||
Step(
|
||||
id="step_a",
|
||||
description="Occurs after step_b even though it is declared first",
|
||||
required=True,
|
||||
detector=Detector(
|
||||
description="Event A",
|
||||
after_step="step_b",
|
||||
),
|
||||
),
|
||||
Step(
|
||||
id="step_b",
|
||||
description="Reference step declared later",
|
||||
required=True,
|
||||
detector=Detector(
|
||||
description="Event B",
|
||||
),
|
||||
),
|
||||
),
|
||||
threshold_promote_to_hook=0.5,
|
||||
)
|
||||
trace = [
|
||||
ObservationEvent(
|
||||
timestamp="2026-03-20T10:00:01Z",
|
||||
event="tool_complete",
|
||||
tool="Write",
|
||||
session="sess-order",
|
||||
input='{"file_path":"src/b.py"}',
|
||||
output="step b",
|
||||
),
|
||||
ObservationEvent(
|
||||
timestamp="2026-03-20T10:00:02Z",
|
||||
event="tool_complete",
|
||||
tool="Write",
|
||||
session="sess-order",
|
||||
input='{"file_path":"src/a.py"}',
|
||||
output="step a",
|
||||
),
|
||||
]
|
||||
mock_cls.return_value = {
|
||||
"step_a": [1],
|
||||
"step_b": [0],
|
||||
}
|
||||
|
||||
result = grade(spec, trace)
|
||||
|
||||
step_a = next(step for step in result.steps if step.step_id == "step_a")
|
||||
step_b = next(step for step in result.steps if step.step_id == "step_b")
|
||||
assert step_a.detected is True
|
||||
assert step_a.failure_reason is None
|
||||
assert step_b.detected is True
|
||||
assert result.compliance_rate == 1.0
|
||||
|
||||
33
src/llm/__init__.py
Normal file
33
src/llm/__init__.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""
|
||||
LLM Abstraction Layer
|
||||
|
||||
Provider-agnostic interface for multiple LLM backends.
|
||||
"""
|
||||
|
||||
from llm.core.interface import LLMProvider
|
||||
from llm.core.types import LLMInput, LLMOutput, Message, ToolCall, ToolDefinition, ToolResult
|
||||
from llm.providers import get_provider
|
||||
from llm.tools import ToolExecutor, ToolRegistry
|
||||
from llm.cli.selector import interactive_select
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
__all__ = (
|
||||
"LLMInput",
|
||||
"LLMOutput",
|
||||
"LLMProvider",
|
||||
"Message",
|
||||
"ToolCall",
|
||||
"ToolDefinition",
|
||||
"ToolResult",
|
||||
"ToolExecutor",
|
||||
"ToolRegistry",
|
||||
"get_provider",
|
||||
"interactive_select",
|
||||
)
|
||||
|
||||
|
||||
def gui() -> None:
|
||||
from llm.cli.selector import main
|
||||
main()
|
||||
|
||||
7
src/llm/__main__.py
Normal file
7
src/llm/__main__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Entry point for llm CLI."""
|
||||
|
||||
from llm.cli.selector import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
0
src/llm/cli/__init__.py
Normal file
0
src/llm/cli/__init__.py
Normal file
154
src/llm/cli/selector.py
Normal file
154
src/llm/cli/selector.py
Normal file
@@ -0,0 +1,154 @@
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class Color(str, Enum):
|
||||
RESET = "\033[0m"
|
||||
BOLD = "\033[1m"
|
||||
GREEN = "\033[92m"
|
||||
YELLOW = "\033[93m"
|
||||
BLUE = "\033[94m"
|
||||
CYAN = "\033[96m"
|
||||
|
||||
|
||||
def print_banner() -> None:
|
||||
banner = f"""{Color.CYAN}
|
||||
╔═══════════════════════════════════════════╗
|
||||
║ LLM Provider Selector ║
|
||||
║ Provider-agnostic AI interactions ║
|
||||
╚═══════════════════════════════════════════╝{Color.RESET}"""
|
||||
print(banner)
|
||||
|
||||
|
||||
def print_providers(providers: list[tuple[str, str]]) -> None:
|
||||
print(f"\n{Color.BOLD}Available Providers:{Color.RESET}\n")
|
||||
for i, (name, desc) in enumerate(providers, 1):
|
||||
print(f" {Color.GREEN}{i}{Color.RESET}. {Color.BOLD}{name}{Color.RESET} - {desc}")
|
||||
|
||||
|
||||
def select_provider(providers: list[tuple[str, str]]) -> str | None:
|
||||
if not providers:
|
||||
print("No providers available.")
|
||||
return None
|
||||
|
||||
print_providers(providers)
|
||||
|
||||
while True:
|
||||
try:
|
||||
choice = input(f"\n{Color.YELLOW}Select provider (1-{len(providers)}): {Color.RESET}").strip()
|
||||
if not choice:
|
||||
return None
|
||||
idx = int(choice) - 1
|
||||
if 0 <= idx < len(providers):
|
||||
return providers[idx][0]
|
||||
print(f"{Color.YELLOW}Invalid selection. Try again.{Color.RESET}")
|
||||
except ValueError:
|
||||
print(f"{Color.YELLOW}Please enter a number.{Color.RESET}")
|
||||
|
||||
|
||||
def select_model(models: list[tuple[str, str]]) -> str | None:
|
||||
if not models:
|
||||
print("No models available.")
|
||||
return None
|
||||
|
||||
print(f"\n{Color.BOLD}Available Models:{Color.RESET}\n")
|
||||
for i, (name, desc) in enumerate(models, 1):
|
||||
print(f" {Color.GREEN}{i}{Color.RESET}. {Color.BOLD}{name}{Color.RESET} - {desc}")
|
||||
|
||||
while True:
|
||||
try:
|
||||
choice = input(f"\n{Color.YELLOW}Select model (1-{len(models)}): {Color.RESET}").strip()
|
||||
if not choice:
|
||||
return None
|
||||
idx = int(choice) - 1
|
||||
if 0 <= idx < len(models):
|
||||
return models[idx][0]
|
||||
print(f"{Color.YELLOW}Invalid selection. Try again.{Color.RESET}")
|
||||
except ValueError:
|
||||
print(f"{Color.YELLOW}Please enter a number.{Color.RESET}")
|
||||
|
||||
|
||||
def save_config(provider: str, model: str, persist: bool = False) -> None:
|
||||
config = f"LLM_PROVIDER={provider}\nLLM_MODEL={model}\n"
|
||||
env_file = ".llm.env"
|
||||
|
||||
with open(env_file, "w") as f:
|
||||
f.write(config)
|
||||
|
||||
print(f"\n{Color.GREEN}✓{Color.RESET} Config saved to {Color.CYAN}{env_file}{Color.RESET}")
|
||||
|
||||
if persist:
|
||||
os.environ["LLM_PROVIDER"] = provider
|
||||
os.environ["LLM_MODEL"] = model
|
||||
print(f"{Color.GREEN}✓{Color.RESET} Config loaded to current session")
|
||||
|
||||
|
||||
def interactive_select(
|
||||
providers: list[tuple[str, str]] | None = None,
|
||||
models_per_provider: dict[str, list[tuple[str, str]]] | None = None,
|
||||
persist: bool = False,
|
||||
) -> tuple[str, str] | None:
|
||||
print_banner()
|
||||
|
||||
if providers is None:
|
||||
providers = [
|
||||
("claude", "Anthropic Claude ( Sonnet, Opus, Haiku)"),
|
||||
("openai", "OpenAI GPT (4o, 4o-mini, 3.5-turbo)"),
|
||||
("ollama", "Local Ollama models"),
|
||||
]
|
||||
|
||||
if models_per_provider is None:
|
||||
models_per_provider = {
|
||||
"claude": [
|
||||
("claude-opus-4-5", "Claude Opus 4.5 - Most capable"),
|
||||
("claude-sonnet-4-7", "Claude Sonnet 4.7 - Balanced"),
|
||||
("claude-haiku-4-7", "Claude Haiku 4.7 - Fast"),
|
||||
],
|
||||
"openai": [
|
||||
("gpt-4o", "GPT-4o - Most capable"),
|
||||
("gpt-4o-mini", "GPT-4o-mini - Fast & affordable"),
|
||||
("gpt-4-turbo", "GPT-4 Turbo - Legacy powerful"),
|
||||
("gpt-3.5-turbo", "GPT-3.5 - Legacy fast"),
|
||||
],
|
||||
"ollama": [
|
||||
("llama3.2", "Llama 3.2 - General purpose"),
|
||||
("mistral", "Mistral - Fast & efficient"),
|
||||
("codellama", "CodeLlama - Code specialized"),
|
||||
],
|
||||
}
|
||||
|
||||
provider = select_provider(providers)
|
||||
if not provider:
|
||||
return None
|
||||
|
||||
models = models_per_provider.get(provider, [])
|
||||
model = select_model(models)
|
||||
if not model:
|
||||
return None
|
||||
|
||||
print(f"\n{Color.GREEN}Selected: {Color.BOLD}{provider}{Color.RESET} / {Color.BOLD}{model}{Color.RESET}")
|
||||
|
||||
save_config(provider, model, persist)
|
||||
|
||||
return (provider, model)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
result = interactive_select(persist=True)
|
||||
|
||||
if result:
|
||||
print(f"\n{Color.GREEN}Ready to use!{Color.RESET}")
|
||||
print(f" export LLM_PROVIDER={result[0]}")
|
||||
print(f" export LLM_MODEL={result[1]}")
|
||||
else:
|
||||
print("\nSelection cancelled.")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1
src/llm/core/__init__.py
Normal file
1
src/llm/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Core module for LLM abstraction layer."""
|
||||
60
src/llm/core/interface.py
Normal file
60
src/llm/core/interface.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""LLM Provider interface definition."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from llm.core.types import LLMInput, LLMOutput, ModelInfo, ProviderType
|
||||
|
||||
|
||||
class LLMProvider(ABC):
|
||||
provider_type: ProviderType
|
||||
|
||||
@abstractmethod
|
||||
def generate(self, input: LLMInput) -> LLMOutput: ...
|
||||
|
||||
@abstractmethod
|
||||
def list_models(self) -> list[ModelInfo]: ...
|
||||
|
||||
@abstractmethod
|
||||
def validate_config(self) -> bool: ...
|
||||
|
||||
def supports_tools(self) -> bool:
|
||||
return True
|
||||
|
||||
def supports_vision(self) -> bool:
|
||||
return False
|
||||
|
||||
def get_default_model(self) -> str:
|
||||
raise NotImplementedError(f"{self.__class__.__name__} must implement get_default_model")
|
||||
|
||||
|
||||
class LLMError(Exception):
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
provider: ProviderType | None = None,
|
||||
code: str | None = None,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.provider = provider
|
||||
self.code = code
|
||||
self.details = details or {}
|
||||
|
||||
|
||||
class AuthenticationError(LLMError): ...
|
||||
|
||||
|
||||
class RateLimitError(LLMError): ...
|
||||
|
||||
|
||||
class ContextLengthError(LLMError): ...
|
||||
|
||||
|
||||
class ModelNotFoundError(LLMError): ...
|
||||
|
||||
|
||||
class ToolExecutionError(LLMError): ...
|
||||
146
src/llm/core/types.py
Normal file
146
src/llm/core/types.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""Core type definitions for LLM abstraction layer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
|
||||
class Role(str, Enum):
|
||||
SYSTEM = "system"
|
||||
USER = "user"
|
||||
ASSISTANT = "assistant"
|
||||
TOOL = "tool"
|
||||
|
||||
|
||||
class ProviderType(str, Enum):
|
||||
CLAUDE = "claude"
|
||||
OPENAI = "openai"
|
||||
OLLAMA = "ollama"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Message:
|
||||
role: Role
|
||||
content: str
|
||||
name: str | None = None
|
||||
tool_call_id: str | None = None
|
||||
tool_calls: list[ToolCall] | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {"role": self.role.value, "content": self.content}
|
||||
if self.name:
|
||||
result["name"] = self.name
|
||||
if self.tool_call_id:
|
||||
result["tool_call_id"] = self.tool_call_id
|
||||
if self.tool_calls:
|
||||
result["tool_calls"] = [
|
||||
{"id": tc.id, "function": {"name": tc.name, "arguments": tc.arguments}}
|
||||
for tc in self.tool_calls
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolDefinition:
|
||||
name: str
|
||||
description: str
|
||||
parameters: dict[str, Any]
|
||||
strict: bool = True
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"parameters": self.parameters,
|
||||
"strict": self.strict,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolCall:
|
||||
id: str
|
||||
name: str
|
||||
arguments: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolResult:
|
||||
tool_call_id: str
|
||||
content: str
|
||||
is_error: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LLMInput:
|
||||
messages: list[Message]
|
||||
model: str | None = None
|
||||
temperature: float = 1.0
|
||||
max_tokens: int | None = None
|
||||
tools: list[ToolDefinition] | None = None
|
||||
stream: bool = False
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {
|
||||
"messages": [msg.to_dict() for msg in self.messages],
|
||||
"temperature": self.temperature,
|
||||
"stream": self.stream,
|
||||
}
|
||||
if self.model:
|
||||
result["model"] = self.model
|
||||
if self.max_tokens is not None:
|
||||
result["max_tokens"] = self.max_tokens
|
||||
if self.tools:
|
||||
result["tools"] = [tool.to_dict() for tool in self.tools]
|
||||
return result | self.metadata
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LLMOutput:
|
||||
content: str
|
||||
tool_calls: list[ToolCall] | None = None
|
||||
model: str | None = None
|
||||
usage: dict[str, int] | None = None
|
||||
stop_reason: str | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def has_tool_calls(self) -> bool:
|
||||
return bool(self.tool_calls)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {"content": self.content}
|
||||
if self.tool_calls:
|
||||
result["tool_calls"] = [
|
||||
{"id": tc.id, "name": tc.name, "arguments": tc.arguments}
|
||||
for tc in self.tool_calls
|
||||
]
|
||||
if self.model:
|
||||
result["model"] = self.model
|
||||
if self.usage:
|
||||
result["usage"] = self.usage
|
||||
if self.stop_reason:
|
||||
result["stop_reason"] = self.stop_reason
|
||||
return result | self.metadata
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelInfo:
|
||||
name: str
|
||||
provider: ProviderType
|
||||
supports_tools: bool = True
|
||||
supports_vision: bool = False
|
||||
max_tokens: int | None = None
|
||||
context_window: int | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"provider": self.provider.value,
|
||||
"supports_tools": self.supports_tools,
|
||||
"supports_vision": self.supports_vision,
|
||||
"max_tokens": self.max_tokens,
|
||||
"context_window": self.context_window,
|
||||
}
|
||||
13
src/llm/prompt/__init__.py
Normal file
13
src/llm/prompt/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Prompt module for prompt building and normalization."""
|
||||
|
||||
from llm.prompt.builder import PromptBuilder, adapt_messages_for_provider, get_provider_builder
|
||||
from llm.prompt.templates import TEMPLATES, get_template, get_template_or_default
|
||||
|
||||
__all__ = (
|
||||
"PromptBuilder",
|
||||
"TEMPLATES",
|
||||
"adapt_messages_for_provider",
|
||||
"get_provider_builder",
|
||||
"get_template",
|
||||
"get_template_or_default",
|
||||
)
|
||||
102
src/llm/prompt/builder.py
Normal file
102
src/llm/prompt/builder.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Prompt builder for normalizing prompts across providers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from llm.core.types import LLMInput, Message, Role, ToolDefinition
|
||||
from llm.providers.claude import ClaudeProvider
|
||||
from llm.providers.openai import OpenAIProvider
|
||||
from llm.providers.ollama import OllamaProvider
|
||||
|
||||
|
||||
@dataclass
|
||||
class PromptConfig:
|
||||
system_template: str | None = None
|
||||
user_template: str | None = None
|
||||
include_tools_in_system: bool = True
|
||||
tool_format: str = "native"
|
||||
|
||||
|
||||
class PromptBuilder:
|
||||
def __init__(self, config: PromptConfig | None = None) -> None:
|
||||
self.config = config or PromptConfig()
|
||||
|
||||
def build(self, messages: list[Message], tools: list[ToolDefinition] | None = None) -> list[Message]:
|
||||
if not messages:
|
||||
return []
|
||||
|
||||
result: list[Message] = []
|
||||
system_parts: list[str] = []
|
||||
|
||||
if self.config.system_template:
|
||||
system_parts.append(self.config.system_template)
|
||||
|
||||
if tools and self.config.include_tools_in_system:
|
||||
tools_desc = self._format_tools(tools)
|
||||
system_parts.append(f"\n\n## Available Tools\n{tools_desc}")
|
||||
|
||||
if messages[0].role == Role.SYSTEM:
|
||||
system_parts.insert(0, messages[0].content)
|
||||
result.insert(0, Message(role=Role.SYSTEM, content="\n\n".join(system_parts)))
|
||||
result.extend(messages[1:])
|
||||
else:
|
||||
if system_parts:
|
||||
result.insert(0, Message(role=Role.SYSTEM, content="\n\n".join(system_parts)))
|
||||
result.extend(messages)
|
||||
|
||||
return result
|
||||
|
||||
def _format_tools(self, tools: list[ToolDefinition]) -> str:
|
||||
lines = []
|
||||
for tool in tools:
|
||||
lines.append(f"### {tool.name}")
|
||||
lines.append(tool.description)
|
||||
if tool.parameters:
|
||||
lines.append("Parameters:")
|
||||
lines.append(self._format_parameters(tool.parameters))
|
||||
return "\n".join(lines)
|
||||
|
||||
def _format_parameters(self, params: dict[str, Any]) -> str:
|
||||
if "properties" not in params:
|
||||
return str(params)
|
||||
lines = []
|
||||
required = params.get("required", [])
|
||||
for name, spec in params["properties"].items():
|
||||
prop_type = spec.get("type", "any")
|
||||
desc = spec.get("description", "")
|
||||
required_mark = "(required)" if name in required else "(optional)"
|
||||
lines.append(f" - {name}: {prop_type} {required_mark} - {desc}")
|
||||
return "\n".join(lines) if lines else str(params)
|
||||
|
||||
|
||||
_PROVIDER_TEMPLATE_MAP: dict[str, dict[str, Any]] = {
|
||||
"claude": {
|
||||
"include_tools_in_system": False,
|
||||
"tool_format": "anthropic",
|
||||
},
|
||||
"openai": {
|
||||
"include_tools_in_system": False,
|
||||
"tool_format": "openai",
|
||||
},
|
||||
"ollama": {
|
||||
"include_tools_in_system": True,
|
||||
"tool_format": "text",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_provider_builder(provider_name: str) -> PromptBuilder:
|
||||
config_dict = _PROVIDER_TEMPLATE_MAP.get(provider_name.lower(), {})
|
||||
config = PromptConfig(**config_dict)
|
||||
return PromptBuilder(config)
|
||||
|
||||
|
||||
def adapt_messages_for_provider(
|
||||
messages: list[Message],
|
||||
provider: str,
|
||||
tools: list[ToolDefinition] | None = None,
|
||||
) -> list[Message]:
|
||||
builder = get_provider_builder(provider)
|
||||
return builder.build(messages, tools)
|
||||
1
src/llm/prompt/templates/__init__.py
Normal file
1
src/llm/prompt/templates/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Templates module for provider-specific prompt templates
|
||||
14
src/llm/providers/__init__.py
Normal file
14
src/llm/providers/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Provider adapters for multiple LLM backends."""
|
||||
|
||||
from llm.providers.claude import ClaudeProvider
|
||||
from llm.providers.openai import OpenAIProvider
|
||||
from llm.providers.ollama import OllamaProvider
|
||||
from llm.providers.resolver import get_provider, register_provider
|
||||
|
||||
__all__ = (
|
||||
"ClaudeProvider",
|
||||
"OpenAIProvider",
|
||||
"OllamaProvider",
|
||||
"get_provider",
|
||||
"register_provider",
|
||||
)
|
||||
105
src/llm/providers/claude.py
Normal file
105
src/llm/providers/claude.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""Claude provider adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from anthropic import Anthropic
|
||||
|
||||
from llm.core.interface import (
|
||||
AuthenticationError,
|
||||
ContextLengthError,
|
||||
LLMProvider,
|
||||
RateLimitError,
|
||||
)
|
||||
from llm.core.types import LLMInput, LLMOutput, Message, ModelInfo, ProviderType, ToolCall
|
||||
|
||||
|
||||
class ClaudeProvider(LLMProvider):
|
||||
provider_type = ProviderType.CLAUDE
|
||||
|
||||
def __init__(self, api_key: str | None = None, base_url: str | None = None) -> None:
|
||||
self.client = Anthropic(api_key=api_key or os.environ.get("ANTHROPIC_API_KEY"), base_url=base_url)
|
||||
self._models = [
|
||||
ModelInfo(
|
||||
name="claude-opus-4-5",
|
||||
provider=ProviderType.CLAUDE,
|
||||
supports_tools=True,
|
||||
supports_vision=True,
|
||||
max_tokens=8192,
|
||||
context_window=200000,
|
||||
),
|
||||
ModelInfo(
|
||||
name="claude-sonnet-4-7",
|
||||
provider=ProviderType.CLAUDE,
|
||||
supports_tools=True,
|
||||
supports_vision=True,
|
||||
max_tokens=8192,
|
||||
context_window=200000,
|
||||
),
|
||||
ModelInfo(
|
||||
name="claude-haiku-4-7",
|
||||
provider=ProviderType.CLAUDE,
|
||||
supports_tools=True,
|
||||
supports_vision=False,
|
||||
max_tokens=4096,
|
||||
context_window=200000,
|
||||
),
|
||||
]
|
||||
|
||||
def generate(self, input: LLMInput) -> LLMOutput:
|
||||
try:
|
||||
params: dict[str, Any] = {
|
||||
"model": input.model or "claude-sonnet-4-7",
|
||||
"messages": [msg.to_dict() for msg in input.messages],
|
||||
"temperature": input.temperature,
|
||||
}
|
||||
if input.max_tokens:
|
||||
params["max_tokens"] = input.max_tokens
|
||||
else:
|
||||
params["max_tokens"] = 8192 # required by Anthropic API
|
||||
if input.tools:
|
||||
params["tools"] = [tool.to_dict() for tool in input.tools]
|
||||
|
||||
response = self.client.messages.create(**params)
|
||||
|
||||
tool_calls = None
|
||||
if response.content and hasattr(response.content[0], "type"):
|
||||
if response.content[0].type == "tool_use":
|
||||
tool_calls = [
|
||||
ToolCall(
|
||||
id=getattr(response.content[0], "id", ""),
|
||||
name=getattr(response.content[0], "name", ""),
|
||||
arguments=getattr(response.content[0].input, "__dict__", {}),
|
||||
)
|
||||
]
|
||||
|
||||
return LLMOutput(
|
||||
content=response.content[0].text if response.content else "",
|
||||
tool_calls=tool_calls,
|
||||
model=response.model,
|
||||
usage={
|
||||
"input_tokens": response.usage.input_tokens,
|
||||
"output_tokens": response.usage.output_tokens,
|
||||
},
|
||||
stop_reason=response.stop_reason,
|
||||
)
|
||||
except Exception as e:
|
||||
msg = str(e)
|
||||
if "401" in msg or "authentication" in msg.lower():
|
||||
raise AuthenticationError(msg, provider=ProviderType.CLAUDE) from e
|
||||
if "429" in msg or "rate_limit" in msg.lower():
|
||||
raise RateLimitError(msg, provider=ProviderType.CLAUDE) from e
|
||||
if "context" in msg.lower() and "length" in msg.lower():
|
||||
raise ContextLengthError(msg, provider=ProviderType.CLAUDE) from e
|
||||
raise
|
||||
|
||||
def list_models(self) -> list[ModelInfo]:
|
||||
return self._models.copy()
|
||||
|
||||
def validate_config(self) -> bool:
|
||||
return bool(self.client.api_key)
|
||||
|
||||
def get_default_model(self) -> str:
|
||||
return "claude-sonnet-4-7"
|
||||
112
src/llm/providers/ollama.py
Normal file
112
src/llm/providers/ollama.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""Ollama provider adapter for local models."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from llm.core.interface import (
|
||||
AuthenticationError,
|
||||
ContextLengthError,
|
||||
LLMProvider,
|
||||
RateLimitError,
|
||||
)
|
||||
from llm.core.types import LLMInput, LLMOutput, Message, ModelInfo, ProviderType, ToolCall
|
||||
|
||||
|
||||
class OllamaProvider(LLMProvider):
|
||||
provider_type = ProviderType.OLLAMA
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str | None = None,
|
||||
default_model: str | None = None,
|
||||
) -> None:
|
||||
self.base_url = base_url or os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
|
||||
self.default_model = default_model or os.environ.get("OLLAMA_MODEL", "llama3.2")
|
||||
self._models = [
|
||||
ModelInfo(
|
||||
name="llama3.2",
|
||||
provider=ProviderType.OLLAMA,
|
||||
supports_tools=False,
|
||||
supports_vision=False,
|
||||
max_tokens=4096,
|
||||
context_window=128000,
|
||||
),
|
||||
ModelInfo(
|
||||
name="mistral",
|
||||
provider=ProviderType.OLLAMA,
|
||||
supports_tools=False,
|
||||
supports_vision=False,
|
||||
max_tokens=4096,
|
||||
context_window=8192,
|
||||
),
|
||||
ModelInfo(
|
||||
name="codellama",
|
||||
provider=ProviderType.OLLAMA,
|
||||
supports_tools=False,
|
||||
supports_vision=False,
|
||||
max_tokens=4096,
|
||||
context_window=16384,
|
||||
),
|
||||
]
|
||||
|
||||
def generate(self, input: LLMInput) -> LLMOutput:
|
||||
import urllib.request
|
||||
import json
|
||||
|
||||
try:
|
||||
url = f"{self.base_url}/api/chat"
|
||||
model = input.model or self.default_model
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": [msg.to_dict() for msg in input.messages],
|
||||
"stream": False,
|
||||
}
|
||||
if input.temperature != 1.0:
|
||||
payload["options"] = {"temperature": input.temperature}
|
||||
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
|
||||
|
||||
with urllib.request.urlopen(req, timeout=60) as response:
|
||||
result = json.loads(response.read().decode("utf-8"))
|
||||
|
||||
content = result.get("message", {}).get("content", "")
|
||||
|
||||
tool_calls = None
|
||||
if result.get("message", {}).get("tool_calls"):
|
||||
tool_calls = [
|
||||
ToolCall(
|
||||
id=tc.get("id", ""),
|
||||
name=tc.get("function", {}).get("name", ""),
|
||||
arguments=tc.get("function", {}).get("arguments", {}),
|
||||
)
|
||||
for tc in result["message"]["tool_calls"]
|
||||
]
|
||||
|
||||
return LLMOutput(
|
||||
content=content,
|
||||
tool_calls=tool_calls,
|
||||
model=model,
|
||||
stop_reason=result.get("done_reason"),
|
||||
)
|
||||
except Exception as e:
|
||||
msg = str(e)
|
||||
if "401" in msg or "connection" in msg.lower():
|
||||
raise AuthenticationError(f"Ollama connection failed: {msg}", provider=ProviderType.OLLAMA) from e
|
||||
if "429" in msg or "rate_limit" in msg.lower():
|
||||
raise RateLimitError(msg, provider=ProviderType.OLLAMA) from e
|
||||
if "context" in msg.lower() and "length" in msg.lower():
|
||||
raise ContextLengthError(msg, provider=ProviderType.OLLAMA) from e
|
||||
raise
|
||||
|
||||
def list_models(self) -> list[ModelInfo]:
|
||||
return self._models.copy()
|
||||
|
||||
def validate_config(self) -> bool:
|
||||
return bool(self.base_url)
|
||||
|
||||
def get_default_model(self) -> str:
|
||||
return self.default_model
|
||||
114
src/llm/providers/openai.py
Normal file
114
src/llm/providers/openai.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""OpenAI provider adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
from llm.core.interface import (
|
||||
AuthenticationError,
|
||||
ContextLengthError,
|
||||
LLMProvider,
|
||||
RateLimitError,
|
||||
)
|
||||
from llm.core.types import LLMInput, LLMOutput, Message, ModelInfo, ProviderType, ToolCall
|
||||
|
||||
|
||||
class OpenAIProvider(LLMProvider):
|
||||
provider_type = ProviderType.OPENAI
|
||||
|
||||
def __init__(self, api_key: str | None = None, base_url: str | None = None) -> None:
|
||||
self.client = OpenAI(api_key=api_key or os.environ.get("OPENAI_API_KEY"), base_url=base_url)
|
||||
self._models = [
|
||||
ModelInfo(
|
||||
name="gpt-4o",
|
||||
provider=ProviderType.OPENAI,
|
||||
supports_tools=True,
|
||||
supports_vision=True,
|
||||
max_tokens=4096,
|
||||
context_window=128000,
|
||||
),
|
||||
ModelInfo(
|
||||
name="gpt-4o-mini",
|
||||
provider=ProviderType.OPENAI,
|
||||
supports_tools=True,
|
||||
supports_vision=True,
|
||||
max_tokens=4096,
|
||||
context_window=128000,
|
||||
),
|
||||
ModelInfo(
|
||||
name="gpt-4-turbo",
|
||||
provider=ProviderType.OPENAI,
|
||||
supports_tools=True,
|
||||
supports_vision=True,
|
||||
max_tokens=4096,
|
||||
context_window=128000,
|
||||
),
|
||||
ModelInfo(
|
||||
name="gpt-3.5-turbo",
|
||||
provider=ProviderType.OPENAI,
|
||||
supports_tools=True,
|
||||
supports_vision=False,
|
||||
max_tokens=4096,
|
||||
context_window=16385,
|
||||
),
|
||||
]
|
||||
|
||||
def generate(self, input: LLMInput) -> LLMOutput:
|
||||
try:
|
||||
params: dict[str, Any] = {
|
||||
"model": input.model or "gpt-4o-mini",
|
||||
"messages": [msg.to_dict() for msg in input.messages],
|
||||
"temperature": input.temperature,
|
||||
}
|
||||
if input.max_tokens:
|
||||
params["max_tokens"] = input.max_tokens
|
||||
if input.tools:
|
||||
params["tools"] = [tool.to_dict() for tool in input.tools]
|
||||
|
||||
response = self.client.chat.completions.create(**params)
|
||||
choice = response.choices[0]
|
||||
|
||||
tool_calls = None
|
||||
if choice.message.tool_calls:
|
||||
tool_calls = [
|
||||
ToolCall(
|
||||
id=tc.id or "",
|
||||
name=tc.function.name,
|
||||
arguments={} if not tc.function.arguments else json.loads(tc.function.arguments),
|
||||
)
|
||||
for tc in choice.message.tool_calls
|
||||
]
|
||||
|
||||
return LLMOutput(
|
||||
content=choice.message.content or "",
|
||||
tool_calls=tool_calls,
|
||||
model=response.model,
|
||||
usage={
|
||||
"prompt_tokens": response.usage.prompt_tokens,
|
||||
"completion_tokens": response.usage.completion_tokens,
|
||||
"total_tokens": response.usage.total_tokens,
|
||||
},
|
||||
stop_reason=choice.finish_reason,
|
||||
)
|
||||
except Exception as e:
|
||||
msg = str(e)
|
||||
if "401" in msg or "authentication" in msg.lower():
|
||||
raise AuthenticationError(msg, provider=ProviderType.OPENAI) from e
|
||||
if "429" in msg or "rate_limit" in msg.lower():
|
||||
raise RateLimitError(msg, provider=ProviderType.OPENAI) from e
|
||||
if "context" in msg.lower() and "length" in msg.lower():
|
||||
raise ContextLengthError(msg, provider=ProviderType.OPENAI) from e
|
||||
raise
|
||||
|
||||
def list_models(self) -> list[ModelInfo]:
|
||||
return self._models.copy()
|
||||
|
||||
def validate_config(self) -> bool:
|
||||
return bool(self.client.api_key)
|
||||
|
||||
def get_default_model(self) -> str:
|
||||
return "gpt-4o-mini"
|
||||
39
src/llm/providers/resolver.py
Normal file
39
src/llm/providers/resolver.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Provider factory and resolver."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from llm.core.interface import LLMProvider
|
||||
from llm.core.types import ProviderType
|
||||
from llm.providers.claude import ClaudeProvider
|
||||
from llm.providers.openai import OpenAIProvider
|
||||
from llm.providers.ollama import OllamaProvider
|
||||
|
||||
|
||||
_PROVIDER_MAP: dict[ProviderType, type[LLMProvider]] = {
|
||||
ProviderType.CLAUDE: ClaudeProvider,
|
||||
ProviderType.OPENAI: OpenAIProvider,
|
||||
ProviderType.OLLAMA: OllamaProvider,
|
||||
}
|
||||
|
||||
|
||||
def get_provider(provider_type: ProviderType | str | None = None, **kwargs: str) -> LLMProvider:
|
||||
if provider_type is None:
|
||||
provider_type = os.environ.get("LLM_PROVIDER", "claude").lower()
|
||||
|
||||
if isinstance(provider_type, str):
|
||||
try:
|
||||
provider_type = ProviderType(provider_type)
|
||||
except ValueError:
|
||||
raise ValueError(f"Unknown provider type: {provider_type}. Valid types: {[p.value for p in ProviderType]}")
|
||||
|
||||
provider_cls = _PROVIDER_MAP.get(provider_type)
|
||||
if not provider_cls:
|
||||
raise ValueError(f"No provider registered for type: {provider_type}")
|
||||
|
||||
return provider_cls(**kwargs)
|
||||
|
||||
|
||||
def register_provider(provider_type: ProviderType, provider_cls: type[LLMProvider]) -> None:
|
||||
_PROVIDER_MAP[provider_type] = provider_cls
|
||||
9
src/llm/tools/__init__.py
Normal file
9
src/llm/tools/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""Tools module for tool/function calling abstraction."""
|
||||
|
||||
from llm.tools.executor import ReActAgent, ToolExecutor, ToolRegistry
|
||||
|
||||
__all__ = (
|
||||
"ReActAgent",
|
||||
"ToolExecutor",
|
||||
"ToolRegistry",
|
||||
)
|
||||
116
src/llm/tools/executor.py
Normal file
116
src/llm/tools/executor.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""Tool executor for handling tool calls from LLM responses."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Callable
|
||||
|
||||
from llm.core.interface import ToolExecutionError
|
||||
from llm.core.types import LLMInput, LLMOutput, Message, Role, ToolCall, ToolDefinition, ToolResult
|
||||
|
||||
|
||||
ToolFunc = Callable[..., Any]
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
def __init__(self) -> None:
|
||||
self._tools: dict[str, ToolFunc] = {}
|
||||
self._definitions: dict[str, ToolDefinition] = {}
|
||||
|
||||
def register(self, definition: ToolDefinition, func: ToolFunc) -> None:
|
||||
self._tools[definition.name] = func
|
||||
self._definitions[definition.name] = definition
|
||||
|
||||
def get(self, name: str) -> ToolFunc | None:
|
||||
return self._tools.get(name)
|
||||
|
||||
def get_definition(self, name: str) -> ToolDefinition | None:
|
||||
return self._definitions.get(name)
|
||||
|
||||
def list_tools(self) -> list[ToolDefinition]:
|
||||
return list(self._definitions.values())
|
||||
|
||||
def has(self, name: str) -> bool:
|
||||
return name in self._tools
|
||||
|
||||
|
||||
class ToolExecutor:
|
||||
def __init__(self, registry: ToolRegistry | None = None) -> None:
|
||||
self.registry = registry or ToolRegistry()
|
||||
|
||||
def execute(self, tool_call: ToolCall) -> ToolResult:
|
||||
func = self.registry.get(tool_call.name)
|
||||
if not func:
|
||||
return ToolResult(
|
||||
tool_call_id=tool_call.id,
|
||||
content=f"Error: Tool '{tool_call.name}' not found",
|
||||
is_error=True,
|
||||
)
|
||||
|
||||
try:
|
||||
result = func(**tool_call.arguments)
|
||||
content = result if isinstance(result, str) else str(result)
|
||||
return ToolResult(tool_call_id=tool_call.id, content=content)
|
||||
except Exception as e:
|
||||
return ToolResult(
|
||||
tool_call_id=tool_call.id,
|
||||
content=f"Error executing {tool_call.name}: {e}",
|
||||
is_error=True,
|
||||
)
|
||||
|
||||
def execute_all(self, tool_calls: list[ToolCall]) -> list[ToolResult]:
|
||||
return [self.execute(tc) for tc in tool_calls]
|
||||
|
||||
|
||||
class ReActAgent:
|
||||
def __init__(
|
||||
self,
|
||||
provider: Any,
|
||||
executor: ToolExecutor,
|
||||
max_iterations: int = 10,
|
||||
) -> None:
|
||||
self.provider = provider
|
||||
self.executor = executor
|
||||
self.max_iterations = max_iterations
|
||||
|
||||
async def run(self, input: LLMInput) -> LLMOutput:
|
||||
messages = list(input.messages)
|
||||
tools = input.tools or []
|
||||
|
||||
for _ in range(self.max_iterations):
|
||||
input_copy = LLMInput(
|
||||
messages=messages,
|
||||
model=input.model,
|
||||
temperature=input.temperature,
|
||||
max_tokens=input.max_tokens,
|
||||
tools=tools,
|
||||
)
|
||||
|
||||
output = self.provider.generate(input_copy)
|
||||
|
||||
if not output.has_tool_calls:
|
||||
return output
|
||||
|
||||
messages.append(
|
||||
Message(
|
||||
role=Role.ASSISTANT,
|
||||
content=output.content or "",
|
||||
tool_calls=output.tool_calls,
|
||||
)
|
||||
)
|
||||
|
||||
results = self.executor.execute_all(output.tool_calls)
|
||||
|
||||
for result in results:
|
||||
messages.append(
|
||||
Message(
|
||||
role=Role.TOOL,
|
||||
content=result.content,
|
||||
tool_call_id=result.tool_call_id,
|
||||
)
|
||||
)
|
||||
|
||||
return LLMOutput(
|
||||
content="Max iterations reached",
|
||||
stop_reason="max_iterations",
|
||||
)
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
90
tests/ci/validate-workflow-security.test.js
Normal file
90
tests/ci/validate-workflow-security.test.js
Normal file
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Validate workflow security guardrails for privileged GitHub Actions events.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const SCRIPT_PATH = path.join(__dirname, '..', '..', 'scripts', 'ci', 'validate-workflow-security.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 runValidator(files) {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-workflow-security-'));
|
||||
try {
|
||||
for (const [name, contents] of Object.entries(files)) {
|
||||
fs.writeFileSync(path.join(tempDir, name), contents);
|
||||
}
|
||||
|
||||
return spawnSync('node', [SCRIPT_PATH], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
ECC_WORKFLOWS_DIR: tempDir,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function run() {
|
||||
console.log('\n=== Testing workflow security validation ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('allows safe workflow_run workflow that only checks out the base repository', () => {
|
||||
const result = runValidator({
|
||||
'safe.yml': `name: Safe\non:\n workflow_run:\n workflows: ["CI"]\n types: [completed]\njobs:\n repair:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - run: echo safe\n`,
|
||||
});
|
||||
assert.strictEqual(result.status, 0, result.stderr || result.stdout);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects workflow_run checkout using github.event.workflow_run.head_branch', () => {
|
||||
const result = runValidator({
|
||||
'unsafe-workflow-run.yml': `name: Unsafe\non:\n workflow_run:\n workflows: ["CI"]\n types: [completed]\njobs:\n repair:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n with:\n ref: \${{ github.event.workflow_run.head_branch }}\n`,
|
||||
});
|
||||
assert.notStrictEqual(result.status, 0, 'Expected validator to fail');
|
||||
assert.match(result.stderr, /workflow_run must not checkout an untrusted workflow_run head ref\/repository/);
|
||||
assert.match(result.stderr, /head_branch/);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects workflow_run checkout using github.event.workflow_run.head_repository.full_name', () => {
|
||||
const result = runValidator({
|
||||
'unsafe-repository.yml': `name: Unsafe\non:\n workflow_run:\n workflows: ["CI"]\n types: [completed]\njobs:\n repair:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n with:\n repository: \${{ github.event.workflow_run.head_repository.full_name }}\n`,
|
||||
});
|
||||
assert.notStrictEqual(result.status, 0, 'Expected validator to fail');
|
||||
assert.match(result.stderr, /head_repository\.full_name/);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects pull_request_target checkout using github.event.pull_request.head.sha', () => {
|
||||
const result = runValidator({
|
||||
'unsafe-pr-target.yml': `name: Unsafe\non:\n pull_request_target:\n branches: [main]\njobs:\n inspect:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n with:\n ref: \${{ github.event.pull_request.head.sha }}\n`,
|
||||
});
|
||||
assert.notStrictEqual(result.status, 0, 'Expected validator to fail');
|
||||
assert.match(result.stderr, /pull_request_target must not checkout an untrusted pull_request head ref\/repository/);
|
||||
assert.match(result.stderr, /pull_request\.head\.sha/);
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nPassed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
run();
|
||||
4
tests/conftest.py
Normal file
4
tests/conftest.py
Normal file
@@ -0,0 +1,4 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
132
tests/hooks/block-no-verify.test.js
Normal file
132
tests/hooks/block-no-verify.test.js
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Tests for scripts/hooks/block-no-verify.js via run-with-flags.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const runner = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'run-with-flags.js');
|
||||
|
||||
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 runHook(input, env = {}) {
|
||||
const rawInput = typeof input === 'string' ? input : JSON.stringify(input);
|
||||
const result = spawnSync('node', [runner, 'pre:bash:block-no-verify', 'scripts/hooks/block-no-verify.js', 'minimal,standard,strict'], {
|
||||
input: rawInput,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
ECC_HOOK_PROFILE: 'standard',
|
||||
...env
|
||||
},
|
||||
timeout: 15000,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
return {
|
||||
code: Number.isInteger(result.status) ? result.status : 1,
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || ''
|
||||
};
|
||||
}
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
console.log('\nblock-no-verify hook tests');
|
||||
console.log('─'.repeat(50));
|
||||
|
||||
// --- Basic allow/block ---
|
||||
|
||||
if (test('allows plain git commit', () => {
|
||||
const r = runHook({ tool_input: { command: 'git commit -m "hello"' } });
|
||||
assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}: ${r.stderr}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('blocks --no-verify on git commit', () => {
|
||||
const r = runHook({ tool_input: { command: 'git commit --no-verify -m "msg"' } });
|
||||
assert.strictEqual(r.code, 2, `expected exit 2, got ${r.code}`);
|
||||
assert.ok(r.stderr.includes('BLOCKED'), `stderr should contain BLOCKED: ${r.stderr}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('blocks -n shorthand on git commit', () => {
|
||||
const r = runHook({ tool_input: { command: 'git commit -n -m "msg"' } });
|
||||
assert.strictEqual(r.code, 2, `expected exit 2, got ${r.code}`);
|
||||
assert.ok(r.stderr.includes('BLOCKED'), `stderr should contain BLOCKED: ${r.stderr}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('blocks core.hooksPath override', () => {
|
||||
const r = runHook({ tool_input: { command: 'git -c core.hooksPath=/dev/null commit -m "msg"' } });
|
||||
assert.strictEqual(r.code, 2, `expected exit 2, got ${r.code}`);
|
||||
assert.ok(r.stderr.includes('core.hooksPath'), `stderr should mention core.hooksPath: ${r.stderr}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Chained command false positive prevention (Comment 2) ---
|
||||
|
||||
if (test('does not false-positive on -n belonging to git log in a chain', () => {
|
||||
const r = runHook({ tool_input: { command: 'git log -n 10 && git commit -m "msg"' } });
|
||||
assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}: ${r.stderr}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('does not false-positive on --no-verify in a prior non-git command', () => {
|
||||
const r = runHook({ tool_input: { command: 'echo --no-verify && git commit -m "msg"' } });
|
||||
assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}: ${r.stderr}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('still blocks --no-verify on the git commit part of a chain', () => {
|
||||
const r = runHook({ tool_input: { command: 'git log -n 5 && git commit --no-verify -m "msg"' } });
|
||||
assert.strictEqual(r.code, 2, `expected exit 2, got ${r.code}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Subcommand detection (Comment 4) ---
|
||||
|
||||
if (test('does not misclassify "commit" as subcommand when it is an argument to push', () => {
|
||||
// "git push origin commit" — "commit" is a refspec arg, not the subcommand
|
||||
const r = runHook({ tool_input: { command: 'git push origin commit' } });
|
||||
// This should detect "push" as the subcommand, not "commit"
|
||||
// Either way it should not block since there's no --no-verify
|
||||
assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}: ${r.stderr}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Blocks on push --no-verify ---
|
||||
|
||||
if (test('blocks --no-verify on git push', () => {
|
||||
const r = runHook({ tool_input: { command: 'git push --no-verify' } });
|
||||
assert.strictEqual(r.code, 2, `expected exit 2, got ${r.code}`);
|
||||
assert.ok(r.stderr.includes('git push'), `stderr should mention git push: ${r.stderr}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Non-git commands pass through ---
|
||||
|
||||
if (test('allows non-git commands', () => {
|
||||
const r = runHook({ tool_input: { command: 'npm test' } });
|
||||
assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}: ${r.stderr}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Plain text input (not JSON) ---
|
||||
|
||||
if (test('handles plain text input', () => {
|
||||
const r = runHook('git commit -m "hello"');
|
||||
assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}: ${r.stderr}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('blocks plain text input with --no-verify', () => {
|
||||
const r = runHook('git commit --no-verify -m "msg"');
|
||||
assert.strictEqual(r.code, 2, `expected exit 2, got ${r.code}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log('─'.repeat(50));
|
||||
console.log(`Passed: ${passed} Failed: ${failed}`);
|
||||
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
437
tests/hooks/gateguard-fact-force.test.js
Normal file
437
tests/hooks/gateguard-fact-force.test.js
Normal file
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* Tests for scripts/hooks/gateguard-fact-force.js via run-with-flags.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const runner = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'run-with-flags.js');
|
||||
const externalStateDir = process.env.GATEGUARD_STATE_DIR;
|
||||
const tmpRoot = process.env.TMPDIR || process.env.TEMP || process.env.TMP || '/tmp';
|
||||
const stateDir = externalStateDir || fs.mkdtempSync(path.join(tmpRoot, 'gateguard-test-'));
|
||||
// Use a fixed session ID so test process and spawned hook process share the same state file
|
||||
const TEST_SESSION_ID = 'gateguard-test-session';
|
||||
const stateFile = path.join(stateDir, `state-${TEST_SESSION_ID}.json`);
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearState() {
|
||||
try {
|
||||
if (fs.existsSync(stateFile)) {
|
||||
fs.unlinkSync(stateFile);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(` [clearState] failed to remove ${stateFile}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function writeExpiredState() {
|
||||
try {
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
const expired = {
|
||||
checked: ['some_file.js', '__bash_session__'],
|
||||
last_active: Date.now() - (31 * 60 * 1000) // 31 minutes ago
|
||||
};
|
||||
fs.writeFileSync(stateFile, JSON.stringify(expired), 'utf8');
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
function writeState(state) {
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
fs.writeFileSync(stateFile, JSON.stringify(state), 'utf8');
|
||||
}
|
||||
|
||||
function runHook(input, env = {}) {
|
||||
const rawInput = typeof input === 'string' ? input : JSON.stringify(input);
|
||||
const result = spawnSync('node', [
|
||||
runner,
|
||||
'pre:edit-write:gateguard-fact-force',
|
||||
'scripts/hooks/gateguard-fact-force.js',
|
||||
'standard,strict'
|
||||
], {
|
||||
input: rawInput,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
ECC_HOOK_PROFILE: 'standard',
|
||||
GATEGUARD_STATE_DIR: stateDir,
|
||||
CLAUDE_SESSION_ID: TEST_SESSION_ID,
|
||||
...env
|
||||
},
|
||||
timeout: 15000,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
return {
|
||||
code: Number.isInteger(result.status) ? result.status : 1,
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || ''
|
||||
};
|
||||
}
|
||||
|
||||
function runBashHook(input, env = {}) {
|
||||
const rawInput = typeof input === 'string' ? input : JSON.stringify(input);
|
||||
const result = spawnSync('node', [
|
||||
runner,
|
||||
'pre:bash:gateguard-fact-force',
|
||||
'scripts/hooks/gateguard-fact-force.js',
|
||||
'standard,strict'
|
||||
], {
|
||||
input: rawInput,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
ECC_HOOK_PROFILE: 'standard',
|
||||
GATEGUARD_STATE_DIR: stateDir,
|
||||
CLAUDE_SESSION_ID: TEST_SESSION_ID,
|
||||
...env
|
||||
},
|
||||
timeout: 15000,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
return {
|
||||
code: Number.isInteger(result.status) ? result.status : 1,
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || ''
|
||||
};
|
||||
}
|
||||
|
||||
function parseOutput(stdout) {
|
||||
try {
|
||||
return JSON.parse(stdout);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing gateguard-fact-force ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
// --- Test 1: denies first Edit per file ---
|
||||
clearState();
|
||||
if (test('denies first Edit per file with fact-forcing message', () => {
|
||||
const input = {
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: '/src/app.js', old_string: 'foo', new_string: 'bar' }
|
||||
};
|
||||
const result = runHook(input);
|
||||
assert.strictEqual(result.code, 0, 'exit code should be 0');
|
||||
const output = parseOutput(result.stdout);
|
||||
assert.ok(output, 'should produce JSON output');
|
||||
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
|
||||
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('Fact-Forcing Gate'));
|
||||
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('import/require'));
|
||||
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('/src/app.js'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Test 2: allows second Edit on same file ---
|
||||
if (test('allows second Edit on same file (gate already passed)', () => {
|
||||
const input = {
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: '/src/app.js', old_string: 'foo', new_string: 'bar' }
|
||||
};
|
||||
const result = runHook(input);
|
||||
assert.strictEqual(result.code, 0, 'exit code should be 0');
|
||||
const output = parseOutput(result.stdout);
|
||||
assert.ok(output, 'should produce valid JSON output');
|
||||
// When allowed, the hook passes through the raw input (no hookSpecificOutput)
|
||||
// OR if hookSpecificOutput exists, it must not be deny
|
||||
if (output.hookSpecificOutput) {
|
||||
assert.notStrictEqual(output.hookSpecificOutput.permissionDecision, 'deny',
|
||||
'should not deny second edit on same file');
|
||||
} else {
|
||||
// Pass-through: output matches original input (allow)
|
||||
assert.strictEqual(output.tool_name, 'Edit', 'pass-through should preserve input');
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Test 3: denies first Write per file ---
|
||||
clearState();
|
||||
if (test('denies first Write per file with fact-forcing message', () => {
|
||||
const input = {
|
||||
tool_name: 'Write',
|
||||
tool_input: { file_path: '/src/new-file.js', content: 'console.log("hello")' }
|
||||
};
|
||||
const result = runHook(input);
|
||||
assert.strictEqual(result.code, 0, 'exit code should be 0');
|
||||
const output = parseOutput(result.stdout);
|
||||
assert.ok(output, 'should produce JSON output');
|
||||
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
|
||||
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('creating'));
|
||||
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('call this new file'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Test 4: denies destructive Bash, allows retry ---
|
||||
clearState();
|
||||
if (test('denies destructive Bash commands, allows retry after facts presented', () => {
|
||||
const input = {
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'rm -rf /important/data' }
|
||||
};
|
||||
|
||||
// First call: should deny
|
||||
const result1 = runBashHook(input);
|
||||
assert.strictEqual(result1.code, 0, 'first call exit code should be 0');
|
||||
const output1 = parseOutput(result1.stdout);
|
||||
assert.ok(output1, 'first call should produce JSON output');
|
||||
assert.strictEqual(output1.hookSpecificOutput.permissionDecision, 'deny');
|
||||
assert.ok(output1.hookSpecificOutput.permissionDecisionReason.includes('Destructive'));
|
||||
assert.ok(output1.hookSpecificOutput.permissionDecisionReason.includes('rollback'));
|
||||
|
||||
// Second call (retry after facts presented): should allow
|
||||
const result2 = runBashHook(input);
|
||||
assert.strictEqual(result2.code, 0, 'second call exit code should be 0');
|
||||
const output2 = parseOutput(result2.stdout);
|
||||
assert.ok(output2, 'second call should produce valid JSON output');
|
||||
if (output2.hookSpecificOutput) {
|
||||
assert.notStrictEqual(output2.hookSpecificOutput.permissionDecision, 'deny',
|
||||
'should not deny destructive bash retry after facts presented');
|
||||
} else {
|
||||
assert.strictEqual(output2.tool_name, 'Bash', 'pass-through should preserve input');
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Test 5: denies first routine Bash, allows second ---
|
||||
clearState();
|
||||
if (test('denies first routine Bash, allows second', () => {
|
||||
const input = {
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'ls -la' }
|
||||
};
|
||||
|
||||
// First call: should deny
|
||||
const result1 = runBashHook(input);
|
||||
assert.strictEqual(result1.code, 0, 'first call exit code should be 0');
|
||||
const output1 = parseOutput(result1.stdout);
|
||||
assert.ok(output1, 'first call should produce JSON output');
|
||||
assert.strictEqual(output1.hookSpecificOutput.permissionDecision, 'deny');
|
||||
|
||||
// Second call: should allow
|
||||
const result2 = runBashHook(input);
|
||||
assert.strictEqual(result2.code, 0, 'second call exit code should be 0');
|
||||
const output2 = parseOutput(result2.stdout);
|
||||
assert.ok(output2, 'second call should produce valid JSON output');
|
||||
if (output2.hookSpecificOutput) {
|
||||
assert.notStrictEqual(output2.hookSpecificOutput.permissionDecision, 'deny',
|
||||
'should not deny second routine bash');
|
||||
} else {
|
||||
assert.strictEqual(output2.tool_name, 'Bash', 'pass-through should preserve input');
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Test 6: session state resets after timeout ---
|
||||
if (test('session state resets after 30-minute timeout', () => {
|
||||
writeExpiredState();
|
||||
const input = {
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: 'some_file.js', old_string: 'a', new_string: 'b' }
|
||||
};
|
||||
const result = runHook(input);
|
||||
assert.strictEqual(result.code, 0, 'exit code should be 0');
|
||||
const output = parseOutput(result.stdout);
|
||||
assert.ok(output, 'should produce JSON output after expired state');
|
||||
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny',
|
||||
'should deny again after session timeout (state was reset)');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Test 7: allows unknown tool names ---
|
||||
clearState();
|
||||
if (test('allows unknown tool names through', () => {
|
||||
const input = {
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: '/src/app.js' }
|
||||
};
|
||||
const result = runHook(input);
|
||||
assert.strictEqual(result.code, 0, 'exit code should be 0');
|
||||
const output = parseOutput(result.stdout);
|
||||
assert.ok(output, 'should produce valid JSON output');
|
||||
if (output.hookSpecificOutput) {
|
||||
assert.notStrictEqual(output.hookSpecificOutput.permissionDecision, 'deny',
|
||||
'should not deny unknown tool');
|
||||
} else {
|
||||
assert.strictEqual(output.tool_name, 'Read', 'pass-through should preserve input');
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Test 8: sanitizes file paths with newlines ---
|
||||
clearState();
|
||||
if (test('sanitizes file paths containing newlines', () => {
|
||||
const input = {
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: '/src/app.js\ninjected content', old_string: 'a', new_string: 'b' }
|
||||
};
|
||||
const result = runHook(input);
|
||||
assert.strictEqual(result.code, 0, 'exit code should be 0');
|
||||
const output = parseOutput(result.stdout);
|
||||
assert.ok(output, 'should produce JSON output');
|
||||
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
|
||||
const reason = output.hookSpecificOutput.permissionDecisionReason;
|
||||
// The file path portion of the reason must not contain any raw newlines
|
||||
// (sanitizePath replaces \n and \r with spaces)
|
||||
const pathLine = reason.split('\n').find(l => l.includes('/src/app.js'));
|
||||
assert.ok(pathLine, 'reason should mention the file path');
|
||||
assert.ok(!pathLine.includes('\n'), 'file path line must not contain raw newlines');
|
||||
assert.ok(!reason.includes('/src/app.js\n'), 'newline after file path should be sanitized');
|
||||
assert.ok(!reason.includes('\ninjected'), 'injected content must not appear on its own line');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Test 9: respects ECC_DISABLED_HOOKS ---
|
||||
clearState();
|
||||
if (test('respects ECC_DISABLED_HOOKS (skips when disabled)', () => {
|
||||
const input = {
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: '/src/disabled.js', old_string: 'a', new_string: 'b' }
|
||||
};
|
||||
const result = runHook(input, {
|
||||
ECC_DISABLED_HOOKS: 'pre:edit-write:gateguard-fact-force'
|
||||
});
|
||||
|
||||
assert.strictEqual(result.code, 0, 'exit code should be 0');
|
||||
const output = parseOutput(result.stdout);
|
||||
assert.ok(output, 'should produce valid JSON output');
|
||||
if (output.hookSpecificOutput) {
|
||||
assert.notStrictEqual(output.hookSpecificOutput.permissionDecision, 'deny',
|
||||
'should not deny when hook is disabled');
|
||||
} else {
|
||||
// When disabled, hook passes through raw input
|
||||
assert.strictEqual(output.tool_name, 'Edit', 'pass-through should preserve input');
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Test 10: MultiEdit gates first unchecked file ---
|
||||
clearState();
|
||||
if (test('denies first MultiEdit with unchecked file', () => {
|
||||
const input = {
|
||||
tool_name: 'MultiEdit',
|
||||
tool_input: {
|
||||
edits: [
|
||||
{ file_path: '/src/multi-a.js', old_string: 'a', new_string: 'b' },
|
||||
{ file_path: '/src/multi-b.js', old_string: 'c', new_string: 'd' }
|
||||
]
|
||||
}
|
||||
};
|
||||
const result = runHook(input);
|
||||
assert.strictEqual(result.code, 0, 'exit code should be 0');
|
||||
const output = parseOutput(result.stdout);
|
||||
assert.ok(output, 'should produce JSON output');
|
||||
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
|
||||
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('Fact-Forcing Gate'));
|
||||
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('/src/multi-a.js'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Test 11: MultiEdit allows after all files gated ---
|
||||
if (test('allows MultiEdit after all files gated', () => {
|
||||
// multi-a.js was gated in test 10; gate multi-b.js
|
||||
const input2 = {
|
||||
tool_name: 'MultiEdit',
|
||||
tool_input: { edits: [{ file_path: '/src/multi-b.js', old_string: 'c', new_string: 'd' }] }
|
||||
};
|
||||
runHook(input2); // gates multi-b.js
|
||||
|
||||
// Now both files are gated — retry should allow
|
||||
const input3 = {
|
||||
tool_name: 'MultiEdit',
|
||||
tool_input: {
|
||||
edits: [
|
||||
{ file_path: '/src/multi-a.js', old_string: 'a', new_string: 'b' },
|
||||
{ file_path: '/src/multi-b.js', old_string: 'c', new_string: 'd' }
|
||||
]
|
||||
}
|
||||
};
|
||||
const result3 = runHook(input3);
|
||||
const output3 = parseOutput(result3.stdout);
|
||||
assert.ok(output3, 'should produce valid JSON');
|
||||
if (output3.hookSpecificOutput) {
|
||||
assert.notStrictEqual(output3.hookSpecificOutput.permissionDecision, 'deny',
|
||||
'should allow MultiEdit after all files gated');
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Test 12: reads refresh active session state ---
|
||||
clearState();
|
||||
if (test('touches last_active on read so active sessions do not age out', () => {
|
||||
const staleButActive = Date.now() - (29 * 60 * 1000);
|
||||
writeState({
|
||||
checked: ['/src/keep-alive.js'],
|
||||
last_active: staleButActive
|
||||
});
|
||||
|
||||
const before = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
||||
assert.strictEqual(before.last_active, staleButActive, 'seed state should use the expected timestamp');
|
||||
|
||||
const result = runHook({
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: '/src/keep-alive.js', old_string: 'a', new_string: 'b' }
|
||||
});
|
||||
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 after = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
||||
assert.ok(after.last_active > staleButActive, 'successful reads should refresh last_active');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Test 13: pruning preserves routine bash gate marker ---
|
||||
clearState();
|
||||
if (test('preserves __bash_session__ when pruning oversized state', () => {
|
||||
const checked = ['__bash_session__'];
|
||||
for (let i = 0; i < 80; i++) checked.push(`__destructive__${i}`);
|
||||
for (let i = 0; i < 700; i++) checked.push(`/src/file-${i}.js`);
|
||||
writeState({ checked, last_active: Date.now() });
|
||||
|
||||
runHook({
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: '/src/newly-gated.js', old_string: 'a', new_string: 'b' }
|
||||
});
|
||||
|
||||
const result = runBashHook({
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'pwd' }
|
||||
});
|
||||
const output = parseOutput(result.stdout);
|
||||
assert.ok(output, 'should produce valid JSON output');
|
||||
if (output.hookSpecificOutput) {
|
||||
assert.notStrictEqual(output.hookSpecificOutput.permissionDecision, 'deny',
|
||||
'routine bash marker should survive pruning');
|
||||
}
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
||||
assert.ok(persisted.checked.includes('__bash_session__'), 'pruned state should retain __bash_session__');
|
||||
assert.ok(persisted.checked.length <= 500, 'pruned state should still honor the checked-entry cap');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Cleanup only the temp directory created by this test file.
|
||||
if (!externalStateDir) {
|
||||
try {
|
||||
if (fs.existsSync(stateDir)) {
|
||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(` [cleanup] failed to remove ${stateDir}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n ${passed} passed, ${failed} failed\n`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawn, spawnSync } = require('child_process');
|
||||
@@ -109,6 +111,39 @@ function waitForFile(filePath, timeoutMs = 5000) {
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${filePath}`);
|
||||
}
|
||||
|
||||
function waitForHttpReady(urlString, timeoutMs = 5000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const { protocol } = new URL(urlString);
|
||||
const client = protocol === 'https:' ? https : http;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const attempt = () => {
|
||||
const req = client.request(urlString, { method: 'GET' }, res => {
|
||||
res.resume();
|
||||
resolve();
|
||||
});
|
||||
|
||||
req.setTimeout(250, () => {
|
||||
req.destroy(new Error('timeout'));
|
||||
});
|
||||
|
||||
req.on('error', error => {
|
||||
if (Date.now() >= deadline) {
|
||||
reject(new Error(`Timed out waiting for ${urlString}: ${error.message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(attempt, 25);
|
||||
});
|
||||
|
||||
req.end();
|
||||
};
|
||||
|
||||
attempt();
|
||||
});
|
||||
}
|
||||
|
||||
async function runTests() {
|
||||
console.log('\n=== Testing mcp-health-check.js ===\n');
|
||||
|
||||
@@ -329,6 +364,7 @@ async function runTests() {
|
||||
|
||||
try {
|
||||
const port = waitForFile(portFile).trim();
|
||||
await waitForHttpReady(`http://127.0.0.1:${port}/mcp`);
|
||||
|
||||
writeConfig(configPath, {
|
||||
mcpServers: {
|
||||
@@ -391,6 +427,7 @@ async function runTests() {
|
||||
|
||||
try {
|
||||
const port = waitForFile(portFile).trim();
|
||||
await waitForHttpReady(`http://127.0.0.1:${port}/mcp`);
|
||||
|
||||
writeConfig(configPath, {
|
||||
mcpServers: {
|
||||
|
||||
@@ -309,6 +309,58 @@ function runTests() {
|
||||
fs.rmSync(repoDir, { recursive: true, force: true });
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
(test('resolves repo-relative paths even when the hook runs from a nested cwd', () => {
|
||||
const tmpHome = makeTempDir();
|
||||
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-activity-tracker-nested-repo-'));
|
||||
|
||||
spawnSync('git', ['init'], { cwd: repoDir, encoding: 'utf8' });
|
||||
spawnSync('git', ['config', 'user.email', 'ecc@example.com'], { cwd: repoDir, encoding: 'utf8' });
|
||||
spawnSync('git', ['config', 'user.name', 'ECC Tests'], { cwd: repoDir, encoding: 'utf8' });
|
||||
|
||||
const srcDir = path.join(repoDir, 'src');
|
||||
const nestedCwd = path.join(repoDir, 'subdir');
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
fs.mkdirSync(nestedCwd, { recursive: true });
|
||||
|
||||
const trackedFile = path.join(srcDir, 'app.ts');
|
||||
fs.writeFileSync(trackedFile, 'const count = 1;\n', 'utf8');
|
||||
spawnSync('git', ['add', 'src/app.ts'], { cwd: repoDir, encoding: 'utf8' });
|
||||
spawnSync('git', ['commit', '-m', 'init'], { cwd: repoDir, encoding: 'utf8' });
|
||||
|
||||
fs.writeFileSync(trackedFile, 'const count = 2;\n', 'utf8');
|
||||
|
||||
const input = {
|
||||
tool_name: 'Write',
|
||||
tool_input: {
|
||||
file_path: 'src/app.ts',
|
||||
content: 'const count = 2;\n',
|
||||
},
|
||||
tool_output: { output: 'updated src/app.ts' },
|
||||
};
|
||||
const result = runScript(input, {
|
||||
...withTempHome(tmpHome),
|
||||
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
|
||||
ECC_SESSION_ID: 'ecc-session-nested-cwd',
|
||||
}, {
|
||||
cwd: nestedCwd,
|
||||
});
|
||||
assert.strictEqual(result.code, 0);
|
||||
|
||||
const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'tool-usage.jsonl');
|
||||
const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim());
|
||||
assert.deepStrictEqual(row.file_events, [
|
||||
{
|
||||
path: 'src/app.ts',
|
||||
action: 'modify',
|
||||
diff_preview: 'const count = 1; -> const count = 2;',
|
||||
patch_preview: '@@ -1 +1 @@\n-const count = 1;\n+const count = 2;',
|
||||
},
|
||||
]);
|
||||
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
fs.rmSync(repoDir, { recursive: true, force: true });
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
(test('prefers ECC_SESSION_ID over CLAUDE_SESSION_ID and redacts bash summaries', () => {
|
||||
const tmpHome = makeTempDir();
|
||||
const input = {
|
||||
|
||||
48
tests/lib/command-plugin-root.test.js
Normal file
48
tests/lib/command-plugin-root.test.js
Normal file
@@ -0,0 +1,48 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const assert = require('assert');
|
||||
const { INLINE_RESOLVE } = require('../../scripts/lib/resolve-ecc-root');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(`PASS ${name}`);
|
||||
passed += 1;
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${name}`);
|
||||
console.error(error.stack || error.message || String(error));
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const sessionsDoc = fs.readFileSync(path.join(__dirname, '..', '..', 'commands', 'sessions.md'), 'utf8');
|
||||
const skillHealthDoc = fs.readFileSync(path.join(__dirname, '..', '..', 'commands', 'skill-health.md'), 'utf8');
|
||||
|
||||
test('sessions command uses shared inline resolver in all node scripts', () => {
|
||||
assert.strictEqual((sessionsDoc.match(/const _r = /g) || []).length, 6);
|
||||
assert.strictEqual((sessionsDoc.match(/\['marketplace','ecc'\]/g) || []).length, 6);
|
||||
assert.strictEqual((sessionsDoc.match(/\['marketplace','everything-claude-code'\]/g) || []).length, 6);
|
||||
assert.strictEqual((sessionsDoc.match(/\['ecc','everything-claude-code'\]/g) || []).length, 6);
|
||||
});
|
||||
|
||||
test('skill-health command uses shared inline resolver in all shell snippets', () => {
|
||||
assert.strictEqual((skillHealthDoc.match(/var r=/g) || []).length, 3);
|
||||
assert.strictEqual((skillHealthDoc.match(/\['marketplace','ecc'\]/g) || []).length, 3);
|
||||
assert.strictEqual((skillHealthDoc.match(/\['marketplace','everything-claude-code'\]/g) || []).length, 3);
|
||||
assert.strictEqual((skillHealthDoc.match(/\['ecc','everything-claude-code'\]/g) || []).length, 3);
|
||||
});
|
||||
|
||||
test('inline resolver covers current and legacy marketplace plugin roots', () => {
|
||||
assert.ok(INLINE_RESOLVE.includes('"marketplace","ecc"'));
|
||||
assert.ok(INLINE_RESOLVE.includes('"marketplace","everything-claude-code"'));
|
||||
});
|
||||
|
||||
console.log(`Passed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -116,10 +116,19 @@ function runTests() {
|
||||
assert.ok(plan.operations.length > 0, 'Should include scaffold operations');
|
||||
assert.ok(
|
||||
plan.operations.some(operation => (
|
||||
operation.sourceRelativePath === '.cursor'
|
||||
&& operation.strategy === 'sync-root-children'
|
||||
operation.sourceRelativePath === '.cursor/hooks.json'
|
||||
&& operation.destinationPath === path.join(projectRoot, '.cursor', 'hooks.json')
|
||||
&& operation.strategy === 'preserve-relative-path'
|
||||
)),
|
||||
'Should flatten the native cursor root'
|
||||
'Should preserve non-rule Cursor platform files'
|
||||
);
|
||||
assert.ok(
|
||||
plan.operations.some(operation => (
|
||||
operation.sourceRelativePath === 'rules/common/agents.md'
|
||||
&& operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-agents.mdc')
|
||||
&& operation.strategy === 'flatten-copy'
|
||||
)),
|
||||
'Should produce Cursor .mdc rules while preferring rules-core over duplicate platform copies'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const CURRENT_PACKAGE_VERSION = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8')
|
||||
).version;
|
||||
|
||||
const {
|
||||
createInstallState,
|
||||
@@ -66,7 +69,7 @@ function runTests() {
|
||||
},
|
||||
],
|
||||
source: {
|
||||
repoVersion: '1.10.0',
|
||||
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: 1,
|
||||
},
|
||||
@@ -100,7 +103,7 @@ function runTests() {
|
||||
},
|
||||
operations: [],
|
||||
source: {
|
||||
repoVersion: '1.10.0',
|
||||
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: 1,
|
||||
},
|
||||
@@ -154,7 +157,7 @@ function runTests() {
|
||||
},
|
||||
operations: [operation],
|
||||
source: {
|
||||
repoVersion: '1.10.0',
|
||||
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: 1,
|
||||
},
|
||||
@@ -208,7 +211,7 @@ function runTests() {
|
||||
skippedModules: [],
|
||||
},
|
||||
source: {
|
||||
repoVersion: '1.10.0',
|
||||
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: 1,
|
||||
},
|
||||
|
||||
@@ -90,20 +90,22 @@ function runTests() {
|
||||
assert.strictEqual(plan.targetRoot, path.join(projectRoot, '.cursor'));
|
||||
assert.strictEqual(plan.installStatePath, path.join(projectRoot, '.cursor', 'ecc-install-state.json'));
|
||||
|
||||
const flattened = plan.operations.find(operation => operation.sourceRelativePath === '.cursor');
|
||||
const hooksJson = plan.operations.find(operation => (
|
||||
normalizedRelativePath(operation.sourceRelativePath) === '.cursor/hooks.json'
|
||||
));
|
||||
const preserved = plan.operations.find(operation => (
|
||||
normalizedRelativePath(operation.sourceRelativePath) === 'rules/common/coding-style.md'
|
||||
));
|
||||
|
||||
assert.ok(flattened, 'Should include .cursor scaffold operation');
|
||||
assert.strictEqual(flattened.strategy, 'sync-root-children');
|
||||
assert.strictEqual(flattened.destinationPath, path.join(projectRoot, '.cursor'));
|
||||
assert.ok(hooksJson, 'Should preserve non-rule Cursor platform config files');
|
||||
assert.strictEqual(hooksJson.strategy, 'preserve-relative-path');
|
||||
assert.strictEqual(hooksJson.destinationPath, path.join(projectRoot, '.cursor', 'hooks.json'));
|
||||
|
||||
assert.ok(preserved, 'Should include flattened rules scaffold operations');
|
||||
assert.strictEqual(preserved.strategy, 'flatten-copy');
|
||||
assert.strictEqual(
|
||||
preserved.destinationPath,
|
||||
path.join(projectRoot, '.cursor', 'rules', 'common-coding-style.md')
|
||||
path.join(projectRoot, '.cursor', 'rules', 'common-coding-style.mdc')
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
@@ -126,16 +128,16 @@ function runTests() {
|
||||
assert.ok(
|
||||
plan.operations.some(operation => (
|
||||
normalizedRelativePath(operation.sourceRelativePath) === 'rules/common/coding-style.md'
|
||||
&& operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-coding-style.md')
|
||||
&& operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-coding-style.mdc')
|
||||
)),
|
||||
'Should flatten common rules into namespaced files'
|
||||
'Should flatten common rules into namespaced .mdc files'
|
||||
);
|
||||
assert.ok(
|
||||
plan.operations.some(operation => (
|
||||
normalizedRelativePath(operation.sourceRelativePath) === 'rules/typescript/testing.md'
|
||||
&& operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'typescript-testing.md')
|
||||
&& operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'typescript-testing.mdc')
|
||||
)),
|
||||
'Should flatten language rules into namespaced files'
|
||||
'Should flatten language rules into namespaced .mdc files'
|
||||
);
|
||||
assert.ok(
|
||||
!plan.operations.some(operation => (
|
||||
@@ -143,6 +145,132 @@ function runTests() {
|
||||
)),
|
||||
'Should not preserve nested rule directories for cursor installs'
|
||||
);
|
||||
assert.ok(
|
||||
!plan.operations.some(operation => (
|
||||
operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-coding-style.md')
|
||||
)),
|
||||
'Should not emit .md Cursor rule files'
|
||||
);
|
||||
assert.ok(
|
||||
!plan.operations.some(operation => (
|
||||
normalizedRelativePath(operation.sourceRelativePath) === 'rules/README.md'
|
||||
)),
|
||||
'Should not install Cursor README docs as runtime rule files'
|
||||
);
|
||||
assert.ok(
|
||||
!plan.operations.some(operation => (
|
||||
normalizedRelativePath(operation.sourceRelativePath) === 'rules/zh/README.md'
|
||||
)),
|
||||
'Should not flatten localized README docs into Cursor rule files'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('plans cursor platform rule files as .mdc and excludes rule README docs', () => {
|
||||
const repoRoot = path.join(__dirname, '..', '..');
|
||||
const projectRoot = '/workspace/app';
|
||||
|
||||
const plan = planInstallTargetScaffold({
|
||||
target: 'cursor',
|
||||
repoRoot,
|
||||
projectRoot,
|
||||
modules: [
|
||||
{
|
||||
id: 'platform-configs',
|
||||
paths: ['.cursor'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.ok(
|
||||
plan.operations.some(operation => (
|
||||
normalizedRelativePath(operation.sourceRelativePath) === '.cursor/rules/common-agents.md'
|
||||
&& operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-agents.mdc')
|
||||
)),
|
||||
'Should rename Cursor platform rule files to .mdc'
|
||||
);
|
||||
assert.ok(
|
||||
!plan.operations.some(operation => (
|
||||
operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-agents.md')
|
||||
)),
|
||||
'Should not preserve .md Cursor platform rule files'
|
||||
);
|
||||
assert.ok(
|
||||
plan.operations.some(operation => (
|
||||
normalizedRelativePath(operation.sourceRelativePath) === '.cursor/hooks.json'
|
||||
&& operation.destinationPath === path.join(projectRoot, '.cursor', 'hooks.json')
|
||||
)),
|
||||
'Should preserve non-rule Cursor platform config files'
|
||||
);
|
||||
assert.ok(
|
||||
!plan.operations.some(operation => (
|
||||
operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'README.mdc')
|
||||
)),
|
||||
'Should not emit Cursor rule README docs as .mdc files'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('deduplicates cursor rule destinations when rules-core and platform-configs overlap', () => {
|
||||
const repoRoot = path.join(__dirname, '..', '..');
|
||||
const projectRoot = '/workspace/app';
|
||||
|
||||
const plan = planInstallTargetScaffold({
|
||||
target: 'cursor',
|
||||
repoRoot,
|
||||
projectRoot,
|
||||
modules: [
|
||||
{
|
||||
id: 'rules-core',
|
||||
paths: ['rules'],
|
||||
},
|
||||
{
|
||||
id: 'platform-configs',
|
||||
paths: ['.cursor'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const commonAgentsDestinations = plan.operations.filter(operation => (
|
||||
operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-agents.mdc')
|
||||
));
|
||||
|
||||
assert.strictEqual(commonAgentsDestinations.length, 1, 'Should keep only one common-agents.mdc operation');
|
||||
assert.strictEqual(
|
||||
normalizedRelativePath(commonAgentsDestinations[0].sourceRelativePath),
|
||||
'rules/common/agents.md',
|
||||
'Should prefer rules-core when cursor platform rules would collide'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('prefers native cursor hooks when hooks-runtime and platform-configs overlap', () => {
|
||||
const repoRoot = path.join(__dirname, '..', '..');
|
||||
const projectRoot = '/workspace/app';
|
||||
|
||||
const plan = planInstallTargetScaffold({
|
||||
target: 'cursor',
|
||||
repoRoot,
|
||||
projectRoot,
|
||||
modules: [
|
||||
{
|
||||
id: 'hooks-runtime',
|
||||
paths: ['hooks', 'scripts/hooks', 'scripts/lib'],
|
||||
},
|
||||
{
|
||||
id: 'platform-configs',
|
||||
paths: ['.cursor'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const hooksDestinations = plan.operations.filter(operation => (
|
||||
operation.destinationPath === path.join(projectRoot, '.cursor', 'hooks')
|
||||
));
|
||||
|
||||
assert.strictEqual(hooksDestinations.length, 1, 'Should keep only one .cursor/hooks scaffold operation');
|
||||
assert.strictEqual(
|
||||
normalizedRelativePath(hooksDestinations[0].sourceRelativePath),
|
||||
'.cursor/hooks',
|
||||
'Should prefer native Cursor hooks over generic hooks-runtime hooks'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('plans antigravity remaps for workflows, skills, and flat rules', () => {
|
||||
|
||||
@@ -13,6 +13,9 @@ const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const CURRENT_PACKAGE_VERSION = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8')
|
||||
).version;
|
||||
|
||||
const { resolveEccRoot, INLINE_RESOLVE } = require('../../scripts/lib/resolve-ecc-root');
|
||||
|
||||
@@ -181,7 +184,7 @@ function runTests() {
|
||||
const homeDir = createTempDir();
|
||||
try {
|
||||
const expected = setupLegacyPluginInstall(homeDir, ['marketplace', 'ecc']);
|
||||
setupPluginCache(homeDir, 'ecc', 'affaan-m', '1.10.0');
|
||||
setupPluginCache(homeDir, 'ecc', 'affaan-m', CURRENT_PACKAGE_VERSION);
|
||||
const result = resolveEccRoot({ envRoot: '', homeDir });
|
||||
assert.strictEqual(result, expected);
|
||||
} finally {
|
||||
@@ -193,7 +196,7 @@ function runTests() {
|
||||
if (test('discovers plugin root from cache directory', () => {
|
||||
const homeDir = createTempDir();
|
||||
try {
|
||||
const expected = setupPluginCache(homeDir, 'ecc', 'affaan-m', '1.10.0');
|
||||
const expected = setupPluginCache(homeDir, 'ecc', 'affaan-m', CURRENT_PACKAGE_VERSION);
|
||||
const result = resolveEccRoot({ envRoot: '', homeDir });
|
||||
assert.strictEqual(result, expected);
|
||||
} finally {
|
||||
@@ -205,7 +208,7 @@ function runTests() {
|
||||
const homeDir = createTempDir();
|
||||
try {
|
||||
const claudeDir = setupStandardInstall(homeDir);
|
||||
setupPluginCache(homeDir, 'ecc', 'affaan-m', '1.10.0');
|
||||
setupPluginCache(homeDir, 'ecc', 'affaan-m', CURRENT_PACKAGE_VERSION);
|
||||
const result = resolveEccRoot({ envRoot: '', homeDir });
|
||||
assert.strictEqual(result, claudeDir,
|
||||
'Standard install should take precedence over plugin cache');
|
||||
@@ -218,7 +221,7 @@ function runTests() {
|
||||
const homeDir = createTempDir();
|
||||
try {
|
||||
setupPluginCache(homeDir, 'everything-claude-code', 'legacy-org', '1.7.0');
|
||||
const expected = setupPluginCache(homeDir, 'ecc', 'affaan-m', '1.10.0');
|
||||
const expected = setupPluginCache(homeDir, 'ecc', 'affaan-m', CURRENT_PACKAGE_VERSION);
|
||||
const result = resolveEccRoot({ envRoot: '', homeDir });
|
||||
// Should find one of them (either is valid)
|
||||
assert.ok(
|
||||
@@ -311,7 +314,7 @@ function runTests() {
|
||||
if (test('INLINE_RESOLVE discovers plugin cache when env var is unset', () => {
|
||||
const homeDir = createTempDir();
|
||||
try {
|
||||
const expected = setupPluginCache(homeDir, 'ecc', 'affaan-m', '1.10.0');
|
||||
const expected = setupPluginCache(homeDir, 'ecc', 'affaan-m', CURRENT_PACKAGE_VERSION);
|
||||
const { execFileSync } = require('child_process');
|
||||
const result = execFileSync('node', [
|
||||
'-e', `console.log(${INLINE_RESOLVE})`,
|
||||
|
||||
@@ -20,6 +20,21 @@ const path = require('path');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const repoRootWithSep = `${repoRoot}${path.sep}`;
|
||||
const packageJsonPath = path.join(repoRoot, 'package.json');
|
||||
const packageLockPath = path.join(repoRoot, 'package-lock.json');
|
||||
const rootAgentsPath = path.join(repoRoot, 'AGENTS.md');
|
||||
const trAgentsPath = path.join(repoRoot, 'docs', 'tr', 'AGENTS.md');
|
||||
const zhCnAgentsPath = path.join(repoRoot, 'docs', 'zh-CN', 'AGENTS.md');
|
||||
const ptBrReadmePath = path.join(repoRoot, 'docs', 'pt-BR', 'README.md');
|
||||
const trReadmePath = path.join(repoRoot, 'docs', 'tr', 'README.md');
|
||||
const rootZhCnReadmePath = path.join(repoRoot, 'README.zh-CN.md');
|
||||
const agentYamlPath = path.join(repoRoot, 'agent.yaml');
|
||||
const versionFilePath = path.join(repoRoot, 'VERSION');
|
||||
const zhCnReadmePath = path.join(repoRoot, 'docs', 'zh-CN', 'README.md');
|
||||
const selectiveInstallArchitecturePath = path.join(repoRoot, 'docs', 'SELECTIVE-INSTALL-ARCHITECTURE.md');
|
||||
const opencodePackageJsonPath = path.join(repoRoot, '.opencode', 'package.json');
|
||||
const opencodePackageLockPath = path.join(repoRoot, '.opencode', 'package-lock.json');
|
||||
const opencodeHooksPluginPath = path.join(repoRoot, '.opencode', 'plugins', 'ecc-hooks.ts');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -64,6 +79,93 @@ function assertSafeRepoRelativePath(relativePath, label) {
|
||||
);
|
||||
}
|
||||
|
||||
const rootPackage = loadJsonObject(packageJsonPath, 'package.json');
|
||||
const packageLock = loadJsonObject(packageLockPath, 'package-lock.json');
|
||||
const opencodePackageLock = loadJsonObject(opencodePackageLockPath, '.opencode/package-lock.json');
|
||||
const expectedVersion = rootPackage.version;
|
||||
|
||||
test('package.json has version field', () => {
|
||||
assert.ok(expectedVersion, 'Expected package.json version field');
|
||||
});
|
||||
|
||||
test('package-lock.json root version matches package.json', () => {
|
||||
assert.strictEqual(packageLock.version, expectedVersion);
|
||||
assert.ok(packageLock.packages && packageLock.packages[''], 'Expected package-lock root package entry');
|
||||
assert.strictEqual(packageLock.packages[''].version, expectedVersion);
|
||||
});
|
||||
|
||||
test('AGENTS.md version line matches package.json', () => {
|
||||
const agentsSource = fs.readFileSync(rootAgentsPath, 'utf8');
|
||||
const match = agentsSource.match(/^\*\*Version:\*\* ([0-9]+\.[0-9]+\.[0-9]+)$/m);
|
||||
assert.ok(match, 'Expected AGENTS.md to declare a top-level version line');
|
||||
assert.strictEqual(match[1], expectedVersion);
|
||||
});
|
||||
|
||||
test('docs/tr/AGENTS.md version line matches package.json', () => {
|
||||
const agentsSource = fs.readFileSync(trAgentsPath, 'utf8');
|
||||
const match = agentsSource.match(/^\*\*Sürüm:\*\* ([0-9]+\.[0-9]+\.[0-9]+)$/m);
|
||||
assert.ok(match, 'Expected docs/tr/AGENTS.md to declare a top-level version line');
|
||||
assert.strictEqual(match[1], expectedVersion);
|
||||
});
|
||||
|
||||
test('docs/zh-CN/AGENTS.md version line matches package.json', () => {
|
||||
const agentsSource = fs.readFileSync(zhCnAgentsPath, 'utf8');
|
||||
const match = agentsSource.match(/^\*\*版本:\*\* ([0-9]+\.[0-9]+\.[0-9]+)$/m);
|
||||
assert.ok(match, 'Expected docs/zh-CN/AGENTS.md to declare a top-level version line');
|
||||
assert.strictEqual(match[1], expectedVersion);
|
||||
});
|
||||
|
||||
test('agent.yaml version matches package.json', () => {
|
||||
const agentYamlSource = fs.readFileSync(agentYamlPath, 'utf8');
|
||||
const match = agentYamlSource.match(/^version:\s*([0-9]+\.[0-9]+\.[0-9]+)$/m);
|
||||
assert.ok(match, 'Expected agent.yaml to declare a top-level version field');
|
||||
assert.strictEqual(match[1], expectedVersion);
|
||||
});
|
||||
|
||||
test('VERSION file matches package.json', () => {
|
||||
const versionFile = fs.readFileSync(versionFilePath, 'utf8').trim();
|
||||
assert.ok(versionFile, 'Expected VERSION file to be non-empty');
|
||||
assert.strictEqual(versionFile, expectedVersion);
|
||||
});
|
||||
|
||||
test('docs/SELECTIVE-INSTALL-ARCHITECTURE.md repoVersion example matches package.json', () => {
|
||||
const source = fs.readFileSync(selectiveInstallArchitecturePath, 'utf8');
|
||||
const match = source.match(/"repoVersion":\s*"([0-9]+\.[0-9]+\.[0-9]+)"/);
|
||||
assert.ok(match, 'Expected docs/SELECTIVE-INSTALL-ARCHITECTURE.md to declare a repoVersion example');
|
||||
assert.strictEqual(match[1], expectedVersion);
|
||||
});
|
||||
|
||||
test('.opencode/plugins/ecc-hooks.ts active plugin banner matches package.json', () => {
|
||||
const source = fs.readFileSync(opencodeHooksPluginPath, 'utf8');
|
||||
const match = source.match(/## Active Plugin: Everything Claude Code v([0-9]+\.[0-9]+\.[0-9]+)/);
|
||||
assert.ok(match, 'Expected .opencode/plugins/ecc-hooks.ts to declare an active plugin banner');
|
||||
assert.strictEqual(match[1], expectedVersion);
|
||||
});
|
||||
|
||||
test('docs/pt-BR/README.md latest release heading matches package.json', () => {
|
||||
const source = fs.readFileSync(ptBrReadmePath, 'utf8');
|
||||
assert.ok(
|
||||
source.includes(`### v${expectedVersion} `),
|
||||
'Expected docs/pt-BR/README.md to advertise the current release heading',
|
||||
);
|
||||
});
|
||||
|
||||
test('docs/tr/README.md latest release heading matches package.json', () => {
|
||||
const source = fs.readFileSync(trReadmePath, 'utf8');
|
||||
assert.ok(
|
||||
source.includes(`### v${expectedVersion} `),
|
||||
'Expected docs/tr/README.md to advertise the current release heading',
|
||||
);
|
||||
});
|
||||
|
||||
test('README.zh-CN.md latest release heading matches package.json', () => {
|
||||
const source = fs.readFileSync(rootZhCnReadmePath, 'utf8');
|
||||
assert.ok(
|
||||
source.includes(`### v${expectedVersion} `),
|
||||
'Expected README.zh-CN.md to advertise the current release heading',
|
||||
);
|
||||
});
|
||||
|
||||
// ── Claude plugin manifest ────────────────────────────────────────────────────
|
||||
console.log('\n=== .claude-plugin/plugin.json ===\n');
|
||||
|
||||
@@ -80,8 +182,12 @@ test('claude plugin.json has version field', () => {
|
||||
assert.ok(claudePlugin.version, 'Expected version field');
|
||||
});
|
||||
|
||||
test('claude plugin.json uses short plugin slug', () => {
|
||||
assert.strictEqual(claudePlugin.name, 'ecc');
|
||||
test('claude plugin.json version matches package.json', () => {
|
||||
assert.strictEqual(claudePlugin.version, expectedVersion);
|
||||
});
|
||||
|
||||
test('claude plugin.json uses published plugin name', () => {
|
||||
assert.strictEqual(claudePlugin.name, 'everything-claude-code');
|
||||
});
|
||||
|
||||
test('claude plugin.json agents is an array', () => {
|
||||
@@ -150,10 +256,14 @@ test('claude marketplace.json keeps only Claude-supported top-level keys', () =>
|
||||
}
|
||||
});
|
||||
|
||||
test('claude marketplace.json has plugins array with a short ecc plugin entry', () => {
|
||||
test('claude marketplace.json has plugins array with the published plugin entry', () => {
|
||||
assert.ok(Array.isArray(claudeMarketplace.plugins) && claudeMarketplace.plugins.length > 0, 'Expected plugins array');
|
||||
assert.strictEqual(claudeMarketplace.name, 'ecc');
|
||||
assert.strictEqual(claudeMarketplace.plugins[0].name, 'ecc');
|
||||
assert.strictEqual(claudeMarketplace.name, 'everything-claude-code');
|
||||
assert.strictEqual(claudeMarketplace.plugins[0].name, 'everything-claude-code');
|
||||
});
|
||||
|
||||
test('claude marketplace.json plugin version matches package.json', () => {
|
||||
assert.strictEqual(claudeMarketplace.plugins[0].version, expectedVersion);
|
||||
});
|
||||
|
||||
// ── Codex plugin manifest ─────────────────────────────────────────────────────
|
||||
@@ -183,6 +293,10 @@ test('codex plugin.json has version field', () => {
|
||||
assert.ok(codexPlugin.version, 'Expected version field');
|
||||
});
|
||||
|
||||
test('codex plugin.json version matches package.json', () => {
|
||||
assert.strictEqual(codexPlugin.version, expectedVersion);
|
||||
});
|
||||
|
||||
test('codex plugin.json skills is a string (not array) per official spec', () => {
|
||||
assert.strictEqual(
|
||||
typeof codexPlugin.skills,
|
||||
@@ -268,6 +382,7 @@ test('marketplace.json exists at .agents/plugins/', () => {
|
||||
});
|
||||
|
||||
const marketplace = loadJsonObject(marketplacePath, '.agents/plugins/marketplace.json');
|
||||
const opencodePackage = loadJsonObject(opencodePackageJsonPath, '.opencode/package.json');
|
||||
|
||||
test('marketplace.json has name field', () => {
|
||||
assert.ok(marketplace.name, 'Expected name field');
|
||||
@@ -284,6 +399,7 @@ test('marketplace.json has plugins array with at least one entry', () => {
|
||||
test('marketplace.json plugin entries have required fields', () => {
|
||||
for (const plugin of marketplace.plugins) {
|
||||
assert.ok(plugin.name, `Plugin entry missing name`);
|
||||
assert.ok(plugin.version, `Plugin "${plugin.name}" missing version`);
|
||||
assert.ok(plugin.source && plugin.source.source, `Plugin "${plugin.name}" missing source.source`);
|
||||
assert.ok(plugin.policy && plugin.policy.installation, `Plugin "${plugin.name}" missing policy.installation`);
|
||||
assert.ok(plugin.category, `Plugin "${plugin.name}" missing category`);
|
||||
@@ -294,6 +410,10 @@ test('marketplace.json plugin entry uses short plugin slug', () => {
|
||||
assert.strictEqual(marketplace.plugins[0].name, 'ecc');
|
||||
});
|
||||
|
||||
test('marketplace.json plugin version matches package.json', () => {
|
||||
assert.strictEqual(marketplace.plugins[0].version, expectedVersion);
|
||||
});
|
||||
|
||||
test('marketplace local plugin path resolves to the repo-root Codex bundle', () => {
|
||||
for (const plugin of marketplace.plugins) {
|
||||
if (!plugin.source || plugin.source.source !== 'local') {
|
||||
@@ -317,6 +437,30 @@ test('marketplace local plugin path resolves to the repo-root Codex bundle', ()
|
||||
}
|
||||
});
|
||||
|
||||
test('.opencode/package.json version matches package.json', () => {
|
||||
assert.strictEqual(opencodePackage.version, expectedVersion);
|
||||
});
|
||||
|
||||
test('.opencode/package-lock.json root version matches package.json', () => {
|
||||
assert.strictEqual(opencodePackageLock.version, expectedVersion);
|
||||
assert.ok(opencodePackageLock.packages && opencodePackageLock.packages[''], 'Expected .opencode/package-lock root package entry');
|
||||
assert.strictEqual(opencodePackageLock.packages[''].version, expectedVersion);
|
||||
});
|
||||
|
||||
test('README version row matches package.json', () => {
|
||||
const readme = fs.readFileSync(path.join(repoRoot, 'README.md'), 'utf8');
|
||||
const match = readme.match(/^\| \*\*Version\*\* \| Plugin \| Plugin \| Reference config \| ([0-9][0-9.]*) \|$/m);
|
||||
assert.ok(match, 'Expected README version summary row');
|
||||
assert.strictEqual(match[1], expectedVersion);
|
||||
});
|
||||
|
||||
test('docs/zh-CN/README.md version row matches package.json', () => {
|
||||
const readme = fs.readFileSync(zhCnReadmePath, 'utf8');
|
||||
const match = readme.match(/^\| \*\*版本\*\* \| 插件 \| 插件 \| 参考配置 \| ([0-9][0-9.]*) \|$/m);
|
||||
assert.ok(match, 'Expected docs/zh-CN/README.md version summary row');
|
||||
assert.strictEqual(match[1], expectedVersion);
|
||||
});
|
||||
|
||||
// ── Summary ───────────────────────────────────────────────────────────────────
|
||||
console.log(`\nPassed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
|
||||
@@ -35,7 +35,7 @@ function main() {
|
||||
["package.json exposes the OpenCode build and prepack hooks", () => {
|
||||
assert.strictEqual(packageJson.scripts["build:opencode"], "node scripts/build-opencode.js")
|
||||
assert.strictEqual(packageJson.scripts.prepack, "npm run build:opencode")
|
||||
assert.ok(packageJson.files.includes(".opencode/dist/"))
|
||||
assert.ok(packageJson.files.includes(".opencode/"))
|
||||
}],
|
||||
["build script generates .opencode/dist", () => {
|
||||
const result = spawnSync("node", [buildScript], {
|
||||
@@ -49,8 +49,9 @@ function main() {
|
||||
const result = spawnSync("npm", ["pack", "--dry-run", "--json"], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
shell: process.platform === "win32",
|
||||
})
|
||||
assert.strictEqual(result.status, 0, result.stderr)
|
||||
assert.strictEqual(result.status, 0, result.error?.message || result.stderr)
|
||||
|
||||
const packOutput = JSON.parse(result.stdout)
|
||||
const packagedPaths = new Set(packOutput[0]?.files?.map((file) => file.path) ?? [])
|
||||
@@ -67,6 +68,42 @@ function main() {
|
||||
packagedPaths.has(".opencode/dist/tools/index.js"),
|
||||
"npm pack should include compiled OpenCode tool output"
|
||||
)
|
||||
assert.ok(
|
||||
packagedPaths.has(".claude-plugin/marketplace.json"),
|
||||
"npm pack should include .claude-plugin/marketplace.json"
|
||||
)
|
||||
assert.ok(
|
||||
packagedPaths.has(".claude-plugin/plugin.json"),
|
||||
"npm pack should include .claude-plugin/plugin.json"
|
||||
)
|
||||
assert.ok(
|
||||
packagedPaths.has(".codex-plugin/plugin.json"),
|
||||
"npm pack should include .codex-plugin/plugin.json"
|
||||
)
|
||||
assert.ok(
|
||||
packagedPaths.has(".agents/plugins/marketplace.json"),
|
||||
"npm pack should include .agents/plugins/marketplace.json"
|
||||
)
|
||||
assert.ok(
|
||||
packagedPaths.has(".opencode/package.json"),
|
||||
"npm pack should include .opencode/package.json"
|
||||
)
|
||||
assert.ok(
|
||||
packagedPaths.has(".opencode/package-lock.json"),
|
||||
"npm pack should include .opencode/package-lock.json"
|
||||
)
|
||||
assert.ok(
|
||||
packagedPaths.has("agent.yaml"),
|
||||
"npm pack should include agent.yaml"
|
||||
)
|
||||
assert.ok(
|
||||
packagedPaths.has("AGENTS.md"),
|
||||
"npm pack should include AGENTS.md"
|
||||
)
|
||||
assert.ok(
|
||||
packagedPaths.has("VERSION"),
|
||||
"npm pack should include VERSION"
|
||||
)
|
||||
}],
|
||||
]
|
||||
|
||||
|
||||
@@ -23,8 +23,6 @@ function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
}
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..', '..');
|
||||
|
||||
function run(args = [], options = {}) {
|
||||
const homeDir = options.homeDir || process.env.HOME;
|
||||
const env = {
|
||||
@@ -132,8 +130,11 @@ function runTests() {
|
||||
const result = run(['--target', 'cursor', 'typescript'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-coding-style.md')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'typescript-testing.md')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-coding-style.mdc')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'typescript-testing.mdc')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-agents.mdc')));
|
||||
assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-agents.md')));
|
||||
assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'README.mdc')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'agents', 'architect.md')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'commands', 'plan.md')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'hooks.json')));
|
||||
@@ -306,7 +307,8 @@ function runTests() {
|
||||
});
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'hooks.json')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-agents.md')));
|
||||
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-agents.mdc')));
|
||||
assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-agents.md')));
|
||||
|
||||
const state = readJson(path.join(projectDir, '.cursor', 'ecc-install-state.json'));
|
||||
assert.strictEqual(state.request.profile, null);
|
||||
@@ -331,7 +333,7 @@ function runTests() {
|
||||
assert.ok(result.stderr.includes('Unknown install module: ghost-module'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('merges hooks into settings.json for claude target install', () => {
|
||||
if (test('installs claude hooks without generating settings.json', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
@@ -341,15 +343,7 @@ function runTests() {
|
||||
|
||||
const claudeRoot = path.join(homeDir, '.claude');
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')), 'hooks.json should be copied');
|
||||
|
||||
const settingsPath = path.join(claudeRoot, 'settings.json');
|
||||
assert.ok(fs.existsSync(settingsPath), 'settings.json should exist after install');
|
||||
|
||||
const settings = readJson(settingsPath);
|
||||
assert.ok(settings.hooks, 'settings.json should contain hooks key');
|
||||
assert.ok(settings.hooks.PreToolUse, 'hooks should include PreToolUse');
|
||||
assert.ok(Array.isArray(settings.hooks.PreToolUse), 'PreToolUse should be an array');
|
||||
assert.ok(settings.hooks.PreToolUse.length > 0, 'PreToolUse should have entries');
|
||||
assert.ok(!fs.existsSync(path.join(claudeRoot, 'settings.json')), 'settings.json should not be created just to install managed hooks');
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
@@ -365,23 +359,11 @@ function runTests() {
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
const claudeRoot = path.join(homeDir, '.claude');
|
||||
const settings = readJson(path.join(claudeRoot, 'settings.json'));
|
||||
const installedHooks = readJson(path.join(claudeRoot, 'hooks', 'hooks.json'));
|
||||
|
||||
const normSep = (s) => s.replace(/\\/g, '/');
|
||||
const expectedFragment = normSep(path.join(claudeRoot, 'scripts', 'hooks', 'auto-tmux-dev.js'));
|
||||
|
||||
const autoTmuxEntry = settings.hooks.PreToolUse.find(entry => entry.id === 'pre:bash:auto-tmux-dev');
|
||||
assert.ok(autoTmuxEntry, 'settings.json should include the auto tmux hook');
|
||||
assert.ok(
|
||||
normSep(autoTmuxEntry.hooks[0].command).includes(expectedFragment),
|
||||
'settings.json should use the installed Claude root for hook commands'
|
||||
);
|
||||
assert.ok(
|
||||
!autoTmuxEntry.hooks[0].command.includes('${CLAUDE_PLUGIN_ROOT}'),
|
||||
'settings.json should not retain CLAUDE_PLUGIN_ROOT placeholders after install'
|
||||
);
|
||||
|
||||
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(
|
||||
@@ -398,7 +380,7 @@ function runTests() {
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('preserves existing settings fields and hook entries when merging hooks', () => {
|
||||
if (test('preserves existing settings.json without mutating it during claude install', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
@@ -423,17 +405,15 @@ function runTests() {
|
||||
const settings = readJson(path.join(claudeRoot, 'settings.json'));
|
||||
assert.strictEqual(settings.effortLevel, 'high', 'existing effortLevel should be preserved');
|
||||
assert.deepStrictEqual(settings.env, { MY_VAR: '1' }, 'existing env should be preserved');
|
||||
assert.ok(settings.hooks, 'hooks should be merged in');
|
||||
assert.ok(settings.hooks.PreToolUse, 'PreToolUse hooks should exist');
|
||||
assert.ok(
|
||||
settings.hooks.PreToolUse.some(entry => JSON.stringify(entry).includes('echo custom-pretool')),
|
||||
'existing PreToolUse entries should be preserved'
|
||||
);
|
||||
assert.ok(settings.hooks.PreToolUse.length > 1, 'ECC PreToolUse hooks should be appended');
|
||||
assert.deepStrictEqual(
|
||||
settings.hooks.UserPromptSubmit,
|
||||
[{ matcher: '*', hooks: [{ type: 'command', command: 'echo custom-submit' }] }],
|
||||
'user-defined hook event types should be preserved'
|
||||
'existing hooks should be left untouched'
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
settings.hooks.PreToolUse,
|
||||
[{ matcher: 'Write', hooks: [{ type: 'command', command: 'echo custom-pretool' }] }],
|
||||
'managed Claude hooks should not be injected into settings.json'
|
||||
);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
@@ -515,7 +495,7 @@ function runTests() {
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('reinstall does not duplicate managed hook entries', () => {
|
||||
if (test('reinstall does not create settings.json when only managed hooks are installed', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
@@ -523,69 +503,43 @@ function runTests() {
|
||||
const firstInstall = run(['--profile', 'core'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(firstInstall.code, 0, firstInstall.stderr);
|
||||
|
||||
const settingsPath = path.join(homeDir, '.claude', 'settings.json');
|
||||
const afterFirstInstall = readJson(settingsPath);
|
||||
const preToolUseLength = afterFirstInstall.hooks.PreToolUse.length;
|
||||
|
||||
const secondInstall = run(['--profile', 'core'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(secondInstall.code, 0, secondInstall.stderr);
|
||||
|
||||
const afterSecondInstall = readJson(settingsPath);
|
||||
assert.strictEqual(
|
||||
afterSecondInstall.hooks.PreToolUse.length,
|
||||
preToolUseLength,
|
||||
'managed hook entries should not duplicate on reinstall'
|
||||
);
|
||||
assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'settings.json')));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('reinstall deduplicates legacy hooks without ids against new managed ids', () => {
|
||||
if (test('reinstall leaves pre-existing hook-based settings.json untouched', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
try {
|
||||
const firstInstall = run(['--profile', 'core'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(firstInstall.code, 0, firstInstall.stderr);
|
||||
|
||||
const settingsPath = path.join(homeDir, '.claude', 'settings.json');
|
||||
const afterFirstInstall = readJson(settingsPath);
|
||||
const legacySettings = JSON.parse(JSON.stringify(afterFirstInstall));
|
||||
|
||||
for (const entries of Object.values(legacySettings.hooks)) {
|
||||
if (!Array.isArray(entries)) {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
delete entry.id;
|
||||
}
|
||||
}
|
||||
|
||||
const claudeRoot = path.join(homeDir, '.claude');
|
||||
fs.mkdirSync(claudeRoot, { recursive: true });
|
||||
const settingsPath = path.join(claudeRoot, 'settings.json');
|
||||
const legacySettings = {
|
||||
hooks: {
|
||||
PreToolUse: [{ matcher: 'Write', hooks: [{ type: 'command', command: 'echo legacy-pretool' }] }],
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(legacySettings, null, 2));
|
||||
const legacyPreToolUseLength = legacySettings.hooks.PreToolUse.length;
|
||||
|
||||
const secondInstall = run(['--profile', 'core'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(secondInstall.code, 0, secondInstall.stderr);
|
||||
|
||||
const afterSecondInstall = readJson(settingsPath);
|
||||
assert.strictEqual(
|
||||
afterSecondInstall.hooks.PreToolUse.length,
|
||||
legacyPreToolUseLength,
|
||||
'legacy hook installs should not duplicate when ids are introduced'
|
||||
);
|
||||
assert.ok(
|
||||
afterSecondInstall.hooks.PreToolUse.every(entry => entry && typeof entry === 'object'),
|
||||
'merged hook entries should remain valid objects'
|
||||
);
|
||||
assert.deepStrictEqual(afterSecondInstall, legacySettings);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('fails when existing settings.json is malformed', () => {
|
||||
if (test('ignores malformed existing settings.json during claude install', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
@@ -596,18 +550,17 @@ function runTests() {
|
||||
fs.writeFileSync(settingsPath, '{ invalid json\n');
|
||||
|
||||
const result = run(['--profile', 'core'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stderr.includes('Failed to parse existing settings at'));
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.strictEqual(fs.readFileSync(settingsPath, 'utf8'), '{ invalid json\n');
|
||||
assert.ok(!fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')), 'hooks.json should not be copied on validation failure');
|
||||
assert.ok(!fs.existsSync(path.join(claudeRoot, 'ecc', 'install-state.json')), 'install state should not be written on validation failure');
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')), 'hooks.json should still be copied');
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'ecc', 'install-state.json')), 'install state should still be written');
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('fails when existing settings.json root is not an object', () => {
|
||||
if (test('ignores non-object existing settings.json during claude install', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
@@ -618,12 +571,10 @@ function runTests() {
|
||||
fs.writeFileSync(settingsPath, '[]\n');
|
||||
|
||||
const result = run(['--profile', 'core'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stderr.includes('Invalid existing settings at'));
|
||||
assert.ok(result.stderr.includes('expected a JSON object'));
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.strictEqual(fs.readFileSync(settingsPath, 'utf8'), '[]\n');
|
||||
assert.ok(!fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')), 'hooks.json should not be copied on validation failure');
|
||||
assert.ok(!fs.existsSync(path.join(claudeRoot, 'ecc', 'install-state.json')), 'install state should not be written on validation failure');
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')), 'hooks.json should still be copied');
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'ecc', 'install-state.json')), 'install state should still be written');
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
@@ -631,26 +582,64 @@ function runTests() {
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('fails when source hooks.json root is not an object before copying files', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
const sourceHooksPath = path.join(REPO_ROOT, 'hooks', 'hooks.json');
|
||||
const originalHooks = fs.readFileSync(sourceHooksPath, 'utf8');
|
||||
const tempDir = createTempDir('install-apply-invalid-hooks-');
|
||||
const targetRoot = path.join(tempDir, '.claude');
|
||||
const installStatePath = path.join(targetRoot, 'ecc', 'install-state.json');
|
||||
const sourceHooksPath = path.join(tempDir, 'hooks.json');
|
||||
|
||||
try {
|
||||
fs.writeFileSync(sourceHooksPath, '[]\n');
|
||||
|
||||
const result = run(['--profile', 'core'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stderr.includes('Invalid hooks config at'));
|
||||
assert.ok(result.stderr.includes('expected a JSON object'));
|
||||
assert.throws(() => {
|
||||
applyInstallPlan({
|
||||
targetRoot,
|
||||
installStatePath,
|
||||
statePreview: {
|
||||
schemaVersion: 'ecc.install.v1',
|
||||
installedAt: new Date().toISOString(),
|
||||
target: {
|
||||
id: 'claude-home',
|
||||
kind: 'home',
|
||||
root: targetRoot,
|
||||
installStatePath,
|
||||
},
|
||||
request: {
|
||||
profile: 'core',
|
||||
modules: [],
|
||||
includeComponents: [],
|
||||
excludeComponents: [],
|
||||
legacyLanguages: [],
|
||||
legacyMode: false,
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: ['hooks-runtime'],
|
||||
skippedModules: [],
|
||||
},
|
||||
source: {
|
||||
repoVersion: null,
|
||||
repoCommit: null,
|
||||
manifestVersion: 1,
|
||||
},
|
||||
operations: [],
|
||||
},
|
||||
adapter: { target: 'claude' },
|
||||
operations: [{
|
||||
kind: 'copy-file',
|
||||
moduleId: 'hooks-runtime',
|
||||
sourcePath: sourceHooksPath,
|
||||
sourceRelativePath: 'hooks/hooks.json',
|
||||
destinationPath: path.join(targetRoot, 'hooks', 'hooks.json'),
|
||||
strategy: 'preserve-relative-path',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
}],
|
||||
});
|
||||
}, /Invalid hooks config at .*expected a JSON object/);
|
||||
|
||||
const claudeRoot = path.join(homeDir, '.claude');
|
||||
assert.ok(!fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')), 'hooks.json should not be copied when source hooks are invalid');
|
||||
assert.ok(!fs.existsSync(path.join(claudeRoot, 'ecc', 'install-state.json')), 'install state should not be written when source hooks are invalid');
|
||||
assert.ok(!fs.existsSync(path.join(targetRoot, 'hooks', 'hooks.json')), 'hooks.json should not be copied when source hooks are invalid');
|
||||
assert.ok(!fs.existsSync(installStatePath), 'install state should not be written when source hooks are invalid');
|
||||
} finally {
|
||||
fs.writeFileSync(sourceHooksPath, originalHooks);
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
cleanup(tempDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
|
||||
@@ -110,6 +110,17 @@ function runTests() {
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (!powerShellCommand) {
|
||||
console.log(' - skipped help text test; PowerShell is not available in PATH');
|
||||
} else if (test('exposes the corrected Claude target help text', () => {
|
||||
const result = run(powerShellCommand, ['--help']);
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.ok(
|
||||
result.stdout.includes('claude (default) - Install ECC into ~/.claude/'),
|
||||
'help text should describe the Claude target as a full ~/.claude install surface'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
@@ -86,6 +86,15 @@ function runTests() {
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('exposes the corrected Claude target help text', () => {
|
||||
const result = run(['--help']);
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.ok(
|
||||
result.stdout.includes('claude (default) - Install ECC into ~/.claude/'),
|
||||
'help text should describe the Claude target as a full ~/.claude install surface'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
71
tests/scripts/manual-hook-install-docs.test.js
Normal file
71
tests/scripts/manual-hook-install-docs.test.js
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Regression coverage for supported manual Claude hook installation guidance.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const README = path.join(__dirname, '..', '..', 'README.md');
|
||||
const HOOKS_README = path.join(__dirname, '..', '..', 'hooks', 'README.md');
|
||||
|
||||
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 runTests() {
|
||||
console.log('\n=== Testing manual hook install docs ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
const readme = fs.readFileSync(README, 'utf8');
|
||||
const hooksReadme = fs.readFileSync(HOOKS_README, 'utf8');
|
||||
|
||||
if (test('README warns against raw hook file copying', () => {
|
||||
assert.ok(
|
||||
readme.includes('Do not copy the raw repo `hooks/hooks.json` into `~/.claude/settings.json` or `~/.claude/hooks/hooks.json`'),
|
||||
'README should warn against unsupported raw hook copying'
|
||||
);
|
||||
assert.ok(
|
||||
readme.includes('bash ./install.sh --target claude --modules hooks-runtime'),
|
||||
'README should document the supported Bash hook install path'
|
||||
);
|
||||
assert.ok(
|
||||
readme.includes('pwsh -File .\\install.ps1 --target claude --modules hooks-runtime'),
|
||||
'README should document the supported PowerShell hook install path'
|
||||
);
|
||||
assert.ok(
|
||||
readme.includes('%USERPROFILE%\\\\.claude'),
|
||||
'README should call out the correct Windows Claude config root'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('hooks/README mirrors supported manual install guidance', () => {
|
||||
assert.ok(
|
||||
hooksReadme.includes('do not paste the raw repo `hooks.json` into `~/.claude/settings.json` or copy it directly into `~/.claude/hooks/hooks.json`'),
|
||||
'hooks/README should warn against unsupported raw hook copying'
|
||||
);
|
||||
assert.ok(
|
||||
hooksReadme.includes('bash ./install.sh --target claude --modules hooks-runtime'),
|
||||
'hooks/README should document the supported Bash hook install path'
|
||||
);
|
||||
assert.ok(
|
||||
hooksReadme.includes('pwsh -File .\\install.ps1 --target claude --modules hooks-runtime'),
|
||||
'hooks/README should document the supported PowerShell hook install path'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
150
tests/scripts/npm-publish-surface.test.js
Normal file
150
tests/scripts/npm-publish-surface.test.js
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Tests for the npm publish surface contract.
|
||||
*/
|
||||
|
||||
const assert = require("assert")
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const { spawnSync } = require("child_process")
|
||||
|
||||
function runTest(name, fn) {
|
||||
try {
|
||||
fn()
|
||||
console.log(` ✓ ${name}`)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}`)
|
||||
console.error(` ${error.message}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePublishPath(value) {
|
||||
return String(value).replace(/\\/g, "/").replace(/\/$/, "")
|
||||
}
|
||||
|
||||
function isCoveredByAncestor(target, roots) {
|
||||
const parts = target.split("/")
|
||||
for (let index = 1; index < parts.length; index += 1) {
|
||||
const ancestor = parts.slice(0, index).join("/")
|
||||
if (roots.has(ancestor)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function buildExpectedPublishPaths(repoRoot) {
|
||||
const modules = JSON.parse(
|
||||
fs.readFileSync(path.join(repoRoot, "manifests", "install-modules.json"), "utf8")
|
||||
).modules
|
||||
|
||||
const extraPaths = [
|
||||
"manifests",
|
||||
"scripts/ecc.js",
|
||||
"scripts/catalog.js",
|
||||
"scripts/claw.js",
|
||||
"scripts/doctor.js",
|
||||
"scripts/status.js",
|
||||
"scripts/sessions-cli.js",
|
||||
"scripts/install-apply.js",
|
||||
"scripts/install-plan.js",
|
||||
"scripts/list-installed.js",
|
||||
"scripts/skill-create-output.js",
|
||||
"scripts/repair.js",
|
||||
"scripts/harness-audit.js",
|
||||
"scripts/session-inspect.js",
|
||||
"scripts/uninstall.js",
|
||||
"scripts/gemini-adapt-agents.js",
|
||||
"scripts/codex/merge-codex-config.js",
|
||||
"scripts/codex/merge-mcp-config.js",
|
||||
".codex-plugin",
|
||||
".mcp.json",
|
||||
"install.sh",
|
||||
"install.ps1",
|
||||
"schemas",
|
||||
"agent.yaml",
|
||||
"VERSION",
|
||||
]
|
||||
|
||||
const combined = new Set(
|
||||
[...modules.flatMap((module) => module.paths || []), ...extraPaths].map(normalizePublishPath)
|
||||
)
|
||||
|
||||
return [...combined]
|
||||
.filter((publishPath) => !isCoveredByAncestor(publishPath, combined))
|
||||
.sort()
|
||||
}
|
||||
|
||||
function main() {
|
||||
console.log("\n=== Testing npm publish surface ===\n")
|
||||
|
||||
let passed = 0
|
||||
let failed = 0
|
||||
|
||||
const repoRoot = path.join(__dirname, "..", "..")
|
||||
const packageJson = JSON.parse(
|
||||
fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")
|
||||
)
|
||||
|
||||
const expectedPublishPaths = buildExpectedPublishPaths(repoRoot)
|
||||
const actualPublishPaths = packageJson.files.map(normalizePublishPath).sort()
|
||||
|
||||
const tests = [
|
||||
["package.json files align to the module graph and explicit runtime allowlist", () => {
|
||||
assert.deepStrictEqual(actualPublishPaths, expectedPublishPaths)
|
||||
}],
|
||||
["npm pack publishes the reduced runtime surface", () => {
|
||||
const result = spawnSync("npm", ["pack", "--dry-run", "--json"], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
shell: process.platform === "win32",
|
||||
})
|
||||
assert.strictEqual(result.status, 0, result.error?.message || result.stderr)
|
||||
|
||||
const packOutput = JSON.parse(result.stdout)
|
||||
const packagedPaths = new Set(packOutput[0]?.files?.map((file) => file.path) ?? [])
|
||||
|
||||
for (const requiredPath of [
|
||||
"scripts/catalog.js",
|
||||
".gemini/GEMINI.md",
|
||||
".claude-plugin/plugin.json",
|
||||
".codex-plugin/plugin.json",
|
||||
"schemas/install-state.schema.json",
|
||||
"skills/backend-patterns/SKILL.md",
|
||||
]) {
|
||||
assert.ok(
|
||||
packagedPaths.has(requiredPath),
|
||||
`npm pack should include ${requiredPath}`
|
||||
)
|
||||
}
|
||||
|
||||
for (const excludedPath of [
|
||||
"contexts/dev.md",
|
||||
"examples/CLAUDE.md",
|
||||
"plugins/README.md",
|
||||
"scripts/ci/catalog.js",
|
||||
"skills/skill-comply/SKILL.md",
|
||||
]) {
|
||||
assert.ok(
|
||||
!packagedPaths.has(excludedPath),
|
||||
`npm pack should not include ${excludedPath}`
|
||||
)
|
||||
}
|
||||
}],
|
||||
]
|
||||
|
||||
for (const [name, fn] of tests) {
|
||||
if (runTest(name, fn)) {
|
||||
passed += 1
|
||||
} else {
|
||||
failed += 1
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nPassed: ${passed}`)
|
||||
console.log(`Failed: ${failed}`)
|
||||
process.exit(failed > 0 ? 1 : 0)
|
||||
}
|
||||
|
||||
main()
|
||||
71
tests/scripts/release.test.js
Normal file
71
tests/scripts/release.test.js
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Source-level tests for scripts/release.sh
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'release.sh');
|
||||
const source = fs.readFileSync(scriptPath, 'utf8');
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing release.sh ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('release script rejects untracked files when checking cleanliness', () => {
|
||||
assert.ok(
|
||||
source.includes('git status --porcelain --untracked-files=all'),
|
||||
'release.sh should use git status --porcelain --untracked-files=all for cleanliness checks'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('release script reruns release metadata sync validation before commit/tag', () => {
|
||||
const syncCheckIndex = source.lastIndexOf('node tests/plugin-manifest.test.js');
|
||||
const commitIndex = source.indexOf('git commit -m "chore: bump plugin version to $VERSION"');
|
||||
|
||||
assert.ok(syncCheckIndex >= 0, 'release.sh should run plugin-manifest.test.js');
|
||||
assert.ok(commitIndex >= 0, 'release.sh should create the release commit');
|
||||
assert.ok(
|
||||
syncCheckIndex < commitIndex,
|
||||
'plugin-manifest.test.js should run before the release commit is created'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('release script verifies npm pack payload after version updates and before commit/tag', () => {
|
||||
const updateIndex = source.indexOf('update_version "$ROOT_PACKAGE_JSON"');
|
||||
const packCheckIndex = source.indexOf('node tests/scripts/build-opencode.test.js');
|
||||
const commitIndex = source.indexOf('git commit -m "chore: bump plugin version to $VERSION"');
|
||||
|
||||
assert.ok(updateIndex >= 0, 'release.sh should update package version fields');
|
||||
assert.ok(packCheckIndex >= 0, 'release.sh should run build-opencode.test.js');
|
||||
assert.ok(commitIndex >= 0, 'release.sh should create the release commit');
|
||||
assert.ok(
|
||||
updateIndex < packCheckIndex,
|
||||
'build-opencode.test.js should run after versioned files are updated'
|
||||
);
|
||||
assert.ok(
|
||||
packCheckIndex < commitIndex,
|
||||
'build-opencode.test.js should run before the release commit is created'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
69
tests/test_builder.py
Normal file
69
tests/test_builder.py
Normal file
@@ -0,0 +1,69 @@
|
||||
import pytest
|
||||
from llm.core.types import LLMInput, Message, Role, ToolDefinition
|
||||
from llm.prompt import PromptBuilder, adapt_messages_for_provider
|
||||
from llm.prompt.builder import PromptConfig
|
||||
|
||||
|
||||
class TestPromptBuilder:
|
||||
def test_build_without_system(self):
|
||||
messages = [Message(role=Role.USER, content="Hello")]
|
||||
builder = PromptBuilder()
|
||||
result = builder.build(messages)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].role == Role.USER
|
||||
|
||||
def test_build_with_system(self):
|
||||
messages = [
|
||||
Message(role=Role.SYSTEM, content="You are helpful."),
|
||||
Message(role=Role.USER, content="Hello"),
|
||||
]
|
||||
builder = PromptBuilder()
|
||||
result = builder.build(messages)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0].role == Role.SYSTEM
|
||||
|
||||
def test_build_adds_system_from_config(self):
|
||||
messages = [Message(role=Role.USER, content="Hello")]
|
||||
builder = PromptBuilder(system_template="You are a pirate.")
|
||||
result = builder.build(messages)
|
||||
|
||||
assert len(result) == 2
|
||||
assert "pirate" in result[0].content
|
||||
|
||||
def test_build_adds_system_from_config(self):
|
||||
messages = [Message(role=Role.USER, content="Hello")]
|
||||
builder = PromptBuilder(config=PromptConfig(system_template="You are a pirate."))
|
||||
result = builder.build(messages)
|
||||
|
||||
assert len(result) == 2
|
||||
assert "pirate" in result[0].content
|
||||
def test_build_with_tools(self):
|
||||
messages = [Message(role=Role.USER, content="Search for something")]
|
||||
tools = [
|
||||
ToolDefinition(name="search", description="Search the web", parameters={}),
|
||||
]
|
||||
builder = PromptBuilder(include_tools_in_system=True)
|
||||
result = builder.build(messages, tools)
|
||||
|
||||
assert len(result) == 2
|
||||
assert "search" in result[0].content
|
||||
assert "Available Tools" in result[0].content
|
||||
|
||||
|
||||
class TestAdaptMessagesForProvider:
|
||||
def test_adapt_for_claude(self):
|
||||
messages = [Message(role=Role.USER, content="Hello")]
|
||||
result = adapt_messages_for_provider(messages, "claude")
|
||||
assert len(result) == 1
|
||||
|
||||
def test_adapt_for_openai(self):
|
||||
messages = [Message(role=Role.USER, content="Hello")]
|
||||
result = adapt_messages_for_provider(messages, "openai")
|
||||
assert len(result) == 1
|
||||
|
||||
def test_adapt_for_ollama(self):
|
||||
messages = [Message(role=Role.USER, content="Hello")]
|
||||
result = adapt_messages_for_provider(messages, "ollama")
|
||||
assert len(result) == 1
|
||||
86
tests/test_executor.py
Normal file
86
tests/test_executor.py
Normal file
@@ -0,0 +1,86 @@
|
||||
import pytest
|
||||
from llm.core.types import ToolCall, ToolDefinition, ToolResult
|
||||
from llm.tools import ToolExecutor, ToolRegistry
|
||||
|
||||
|
||||
class TestToolRegistry:
|
||||
def test_register_and_get(self):
|
||||
registry = ToolRegistry()
|
||||
|
||||
def dummy_func() -> str:
|
||||
return "result"
|
||||
|
||||
tool_def = ToolDefinition(
|
||||
name="dummy",
|
||||
description="A dummy tool",
|
||||
parameters={"type": "object"},
|
||||
)
|
||||
registry.register(tool_def, dummy_func)
|
||||
|
||||
assert registry.has("dummy") is True
|
||||
assert registry.get("dummy") is dummy_func
|
||||
assert registry.get_definition("dummy") == tool_def
|
||||
|
||||
def test_list_tools(self):
|
||||
registry = ToolRegistry()
|
||||
tool_def = ToolDefinition(name="test", description="Test", parameters={})
|
||||
registry.register(tool_def, lambda: None)
|
||||
|
||||
tools = registry.list_tools()
|
||||
assert len(tools) == 1
|
||||
assert tools[0].name == "test"
|
||||
|
||||
|
||||
class TestToolExecutor:
|
||||
def test_execute_success(self):
|
||||
registry = ToolRegistry()
|
||||
|
||||
def search(query: str) -> str:
|
||||
return f"Results for: {query}"
|
||||
|
||||
registry.register(
|
||||
ToolDefinition(
|
||||
name="search",
|
||||
description="Search",
|
||||
parameters={"type": "object", "properties": {"query": {"type": "string"}}},
|
||||
),
|
||||
search,
|
||||
)
|
||||
|
||||
executor = ToolExecutor(registry)
|
||||
result = executor.execute(ToolCall(id="1", name="search", arguments={"query": "test"}))
|
||||
|
||||
assert result.tool_call_id == "1"
|
||||
assert result.content == "Results for: test"
|
||||
assert result.is_error is False
|
||||
|
||||
def test_execute_unknown_tool(self):
|
||||
registry = ToolRegistry()
|
||||
executor = ToolExecutor(registry)
|
||||
|
||||
result = executor.execute(ToolCall(id="1", name="unknown", arguments={}))
|
||||
|
||||
assert result.is_error is True
|
||||
assert "not found" in result.content
|
||||
|
||||
def test_execute_all(self):
|
||||
registry = ToolRegistry()
|
||||
|
||||
def tool1() -> str:
|
||||
return "result1"
|
||||
|
||||
def tool2() -> str:
|
||||
return "result2"
|
||||
|
||||
registry.register(ToolDefinition(name="t1", description="", parameters={}), tool1)
|
||||
registry.register(ToolDefinition(name="t2", description="", parameters={}), tool2)
|
||||
|
||||
executor = ToolExecutor(registry)
|
||||
results = executor.execute_all([
|
||||
ToolCall(id="1", name="t1", arguments={}),
|
||||
ToolCall(id="2", name="t2", arguments={}),
|
||||
])
|
||||
|
||||
assert len(results) == 2
|
||||
assert results[0].content == "result1"
|
||||
assert results[1].content == "result2"
|
||||
28
tests/test_resolver.py
Normal file
28
tests/test_resolver.py
Normal file
@@ -0,0 +1,28 @@
|
||||
import pytest
|
||||
from llm.core.types import ProviderType
|
||||
from llm.providers import ClaudeProvider, OpenAIProvider, OllamaProvider, get_provider
|
||||
|
||||
|
||||
class TestGetProvider:
|
||||
def test_get_claude_provider(self):
|
||||
provider = get_provider("claude")
|
||||
assert isinstance(provider, ClaudeProvider)
|
||||
assert provider.provider_type == ProviderType.CLAUDE
|
||||
|
||||
def test_get_openai_provider(self):
|
||||
provider = get_provider("openai")
|
||||
assert isinstance(provider, OpenAIProvider)
|
||||
assert provider.provider_type == ProviderType.OPENAI
|
||||
|
||||
def test_get_ollama_provider(self):
|
||||
provider = get_provider("ollama")
|
||||
assert isinstance(provider, OllamaProvider)
|
||||
assert provider.provider_type == ProviderType.OLLAMA
|
||||
|
||||
def test_get_provider_by_enum(self):
|
||||
provider = get_provider(ProviderType.CLAUDE)
|
||||
assert isinstance(provider, ClaudeProvider)
|
||||
|
||||
def test_invalid_provider_raises(self):
|
||||
with pytest.raises(ValueError, match="Unknown provider type"):
|
||||
get_provider("invalid")
|
||||
117
tests/test_types.py
Normal file
117
tests/test_types.py
Normal file
@@ -0,0 +1,117 @@
|
||||
import pytest
|
||||
from llm.core.types import (
|
||||
LLMInput,
|
||||
LLMOutput,
|
||||
Message,
|
||||
ModelInfo,
|
||||
ProviderType,
|
||||
Role,
|
||||
ToolCall,
|
||||
ToolDefinition,
|
||||
ToolResult,
|
||||
)
|
||||
|
||||
|
||||
class TestRole:
|
||||
def test_role_values(self):
|
||||
assert Role.SYSTEM.value == "system"
|
||||
assert Role.USER.value == "user"
|
||||
assert Role.ASSISTANT.value == "assistant"
|
||||
assert Role.TOOL.value == "tool"
|
||||
|
||||
|
||||
class TestProviderType:
|
||||
def test_provider_values(self):
|
||||
assert ProviderType.CLAUDE.value == "claude"
|
||||
assert ProviderType.OPENAI.value == "openai"
|
||||
assert ProviderType.OLLAMA.value == "ollama"
|
||||
|
||||
|
||||
class TestMessage:
|
||||
def test_create_message(self):
|
||||
msg = Message(role=Role.USER, content="Hello")
|
||||
assert msg.role == Role.USER
|
||||
assert msg.content == "Hello"
|
||||
assert msg.name is None
|
||||
assert msg.tool_call_id is None
|
||||
|
||||
def test_message_to_dict(self):
|
||||
msg = Message(role=Role.USER, content="Hello", name="test")
|
||||
result = msg.to_dict()
|
||||
assert result["role"] == "user"
|
||||
assert result["content"] == "Hello"
|
||||
assert result["name"] == "test"
|
||||
|
||||
|
||||
class TestToolDefinition:
|
||||
def test_create_tool(self):
|
||||
tool = ToolDefinition(
|
||||
name="search",
|
||||
description="Search the web",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
)
|
||||
assert tool.name == "search"
|
||||
assert tool.strict is True
|
||||
|
||||
def test_tool_to_dict(self):
|
||||
tool = ToolDefinition(
|
||||
name="search",
|
||||
description="Search",
|
||||
parameters={"type": "object"},
|
||||
)
|
||||
result = tool.to_dict()
|
||||
assert result["name"] == "search"
|
||||
assert result["strict"] is True
|
||||
|
||||
|
||||
class TestToolCall:
|
||||
def test_create_tool_call(self):
|
||||
tc = ToolCall(id="1", name="search", arguments={"query": "test"})
|
||||
assert tc.id == "1"
|
||||
assert tc.name == "search"
|
||||
assert tc.arguments == {"query": "test"}
|
||||
|
||||
|
||||
class TestToolResult:
|
||||
def test_create_tool_result(self):
|
||||
result = ToolResult(tool_call_id="1", content="result")
|
||||
assert result.tool_call_id == "1"
|
||||
assert result.is_error is False
|
||||
|
||||
|
||||
class TestLLMInput:
|
||||
def test_create_input(self):
|
||||
messages = [Message(role=Role.USER, content="Hello")]
|
||||
input_obj = LLMInput(messages=messages, temperature=0.7)
|
||||
assert len(input_obj.messages) == 1
|
||||
assert input_obj.temperature == 0.7
|
||||
|
||||
def test_input_to_dict(self):
|
||||
messages = [Message(role=Role.USER, content="Hello")]
|
||||
input_obj = LLMInput(messages=messages)
|
||||
result = input_obj.to_dict()
|
||||
assert "messages" in result
|
||||
assert result["temperature"] == 1.0
|
||||
|
||||
|
||||
class TestLLMOutput:
|
||||
def test_create_output(self):
|
||||
output = LLMOutput(content="Hello!")
|
||||
assert output.content == "Hello!"
|
||||
assert output.has_tool_calls is False
|
||||
|
||||
def test_output_with_tool_calls(self):
|
||||
tc = ToolCall(id="1", name="search", arguments={})
|
||||
output = LLMOutput(content="", tool_calls=[tc])
|
||||
assert output.has_tool_calls is True
|
||||
|
||||
|
||||
class TestModelInfo:
|
||||
def test_create_model_info(self):
|
||||
info = ModelInfo(
|
||||
name="gpt-4",
|
||||
provider=ProviderType.OPENAI,
|
||||
)
|
||||
assert info.name == "gpt-4"
|
||||
assert info.supports_tools is True
|
||||
assert info.supports_vision is False
|
||||
20
yarn.lock
20
yarn.lock
@@ -240,12 +240,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/node@npm:^20.19.24":
|
||||
version: 20.19.39
|
||||
resolution: "@types/node@npm:20.19.39"
|
||||
"@types/node@npm:^25.6.0":
|
||||
version: 25.6.0
|
||||
resolution: "@types/node@npm:25.6.0"
|
||||
dependencies:
|
||||
undici-types: "npm:~6.21.0"
|
||||
checksum: 10c0/1d16da7b5f47a7415b827fcf3b94d279febf4c14671afec74a03e47856b5270023d9beb1b9aeab4d3b622fd97d61a60206cfc2cca588663181331bc592468289
|
||||
undici-types: "npm:~7.19.0"
|
||||
checksum: 10c0/d2d2015630ff098a201407f55f5077a20270ae4f465c739b40865cd9933b91b9c5d2b85568eadaf3db0801b91e267333ca7eb39f007428b173d1cdab4b339ac5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -549,7 +549,7 @@ __metadata:
|
||||
"@eslint/js": "npm:^9.39.2"
|
||||
"@iarna/toml": "npm:^2.2.5"
|
||||
"@opencode-ai/plugin": "npm:^1.0.0"
|
||||
"@types/node": "npm:^20.19.24"
|
||||
"@types/node": "npm:^25.6.0"
|
||||
ajv: "npm:^8.18.0"
|
||||
c8: "npm:^11.0.0"
|
||||
eslint: "npm:^9.39.2"
|
||||
@@ -1810,10 +1810,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"undici-types@npm:~6.21.0":
|
||||
version: 6.21.0
|
||||
resolution: "undici-types@npm:6.21.0"
|
||||
checksum: 10c0/c01ed51829b10aa72fc3ce64b747f8e74ae9b60eafa19a7b46ef624403508a54c526ffab06a14a26b3120d055e1104d7abe7c9017e83ced038ea5cf52f8d5e04
|
||||
"undici-types@npm:~7.19.0":
|
||||
version: 7.19.2
|
||||
resolution: "undici-types@npm:7.19.2"
|
||||
checksum: 10c0/7159f10546f9f6c47d36776bb1bbf8671e87c1e587a6fee84ae1f111ae8de4f914efa8ca0dfcd224f4f4a9dfc3f6028f627ccb5ddaccf82d7fd54671b89fac3e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user