mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-13 03:33:15 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e555c6a4b7 | |||
| 463eb5fb41 |
@@ -6,7 +6,6 @@
|
||||
"plugins": [
|
||||
{
|
||||
"name": "ecc",
|
||||
"version": "1.10.0",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "../.."
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "everything-claude-code",
|
||||
"name": "ecc",
|
||||
"owner": {
|
||||
"name": "Affaan Mustafa",
|
||||
"email": "me@affaanmustafa.com"
|
||||
@@ -9,7 +9,7 @@
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "everything-claude-code",
|
||||
"name": "ecc",
|
||||
"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": "everything-claude-code",
|
||||
"name": "ecc",
|
||||
"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": {
|
||||
|
||||
@@ -190,10 +190,6 @@ 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
|
||||
|
||||
@@ -38,21 +38,18 @@ jobs:
|
||||
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
- name: Verify package version matches tag
|
||||
- name: Verify plugin.json version matches tag
|
||||
env:
|
||||
TAG_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
TAG_VERSION="${TAG_NAME#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)"
|
||||
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)"
|
||||
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:
|
||||
|
||||
@@ -47,21 +47,6 @@ 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 }}
|
||||
|
||||
@@ -42,9 +42,6 @@ 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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Everything Claude Code (ECC) — Agent Instructions
|
||||
|
||||
This is a **production-ready AI coding plugin** providing 47 specialized agents, 181 skills, 80 commands, and automated hook workflows for software development.
|
||||
This is a **production-ready AI coding plugin** providing 47 specialized agents, 181 skills, 79 commands, and automated hook workflows for software development.
|
||||
|
||||
**Version:** 1.10.0
|
||||
|
||||
@@ -147,7 +147,7 @@ Troubleshoot failures: check test isolation → verify mocks → fix implementat
|
||||
```
|
||||
agents/ — 47 specialized subagents
|
||||
skills/ — 181 workflow skills and domain knowledge
|
||||
commands/ — 80 slash commands
|
||||
commands/ — 79 slash commands
|
||||
hooks/ — Trigger-based automations
|
||||
rules/ — Always-follow guidelines (common + per-language)
|
||||
scripts/ — Cross-platform Node.js utilities
|
||||
|
||||
@@ -179,7 +179,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
|
||||
@@ -238,7 +238,7 @@ For manual install instructions see the README in the `rules/` folder. When copy
|
||||
/plugin list ecc@ecc
|
||||
```
|
||||
|
||||
**That's it!** You now have access to 47 agents, 181 skills, and 80 legacy command shims.
|
||||
**That's it!** You now have access to 47 agents, 181 skills, and 79 legacy command shims.
|
||||
|
||||
### Multi-model commands require additional setup
|
||||
|
||||
@@ -705,9 +705,7 @@ cp everything-claude-code/commands/*.md ~/.claude/commands/
|
||||
|
||||
#### Add hooks to settings.json
|
||||
|
||||
Manual install only: copy the hooks from `hooks/hooks.json` to your `~/.claude/settings.json` if you are not installing ECC as a Claude plugin.
|
||||
|
||||
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.
|
||||
Copy the hooks from `hooks/hooks.json` to your `~/.claude/settings.json`.
|
||||
|
||||
#### Configure MCPs
|
||||
|
||||
@@ -1158,7 +1156,7 @@ The configuration is automatically detected from `.opencode/opencode.json`.
|
||||
| Feature | Claude Code | OpenCode | Status |
|
||||
|---------|-------------|----------|--------|
|
||||
| Agents | PASS: 47 agents | PASS: 12 agents | **Claude Code leads** |
|
||||
| Commands | PASS: 80 commands | PASS: 31 commands | **Claude Code leads** |
|
||||
| Commands | PASS: 79 commands | PASS: 31 commands | **Claude Code leads** |
|
||||
| Skills | PASS: 181 skills | PASS: 37 skills | **Claude Code leads** |
|
||||
| Hooks | PASS: 8 event types | PASS: 11 events | **OpenCode has more!** |
|
||||
| Rules | PASS: 29 rules | PASS: 13 instructions | **Claude Code leads** |
|
||||
@@ -1267,7 +1265,7 @@ ECC is the **first plugin to maximize every major AI coding tool**. Here's how e
|
||||
| Feature | Claude Code | Cursor IDE | Codex CLI | OpenCode |
|
||||
|---------|------------|------------|-----------|----------|
|
||||
| **Agents** | 47 | Shared (AGENTS.md) | Shared (AGENTS.md) | 12 |
|
||||
| **Commands** | 80 | Shared | Instruction-based | 31 |
|
||||
| **Commands** | 79 | Shared | Instruction-based | 31 |
|
||||
| **Skills** | 181 | Shared | 10 (native format) | 37 |
|
||||
| **Hook Events** | 8 types | 15 types | None yet | 11 types |
|
||||
| **Hook Scripts** | 20+ scripts | 16 scripts (DRY adapter) | N/A | Plugin hooks |
|
||||
|
||||
+2
-15
@@ -78,17 +78,6 @@
|
||||
|
||||
---
|
||||
|
||||
## 最新动态
|
||||
|
||||
### 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 分钟内快速上手:
|
||||
@@ -162,7 +151,7 @@ npx ecc-install typescript
|
||||
/plugin list ecc@ecc
|
||||
```
|
||||
|
||||
**完成!** 你现在可以使用 47 个代理、181 个技能和 80 个命令。
|
||||
**完成!** 你现在可以使用 47 个代理、181 个技能和 79 个命令。
|
||||
|
||||
### multi-* 命令需要额外配置
|
||||
|
||||
@@ -626,9 +615,7 @@ cp everything-claude-code/commands/*.md ~/.claude/commands/
|
||||
```
|
||||
|
||||
#### 将钩子配置添加到 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}` 无法解析。
|
||||
将 `hooks/hooks.json` 中的钩子配置复制到你的 `~/.claude/settings.json` 文件中。
|
||||
|
||||
#### 配置 MCP 服务
|
||||
从 `mcp-configs/mcp-servers.json` 中复制需要的 MCP 服务定义,粘贴到官方 Claude Code 配置文件 `~/.claude/settings.json` 中;
|
||||
|
||||
@@ -146,7 +146,6 @@ skills:
|
||||
commands:
|
||||
- agent-sort
|
||||
- aside
|
||||
- auto-update
|
||||
- build-fix
|
||||
- checkpoint
|
||||
- claw
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
---
|
||||
description: Pull the latest ECC repo changes and reinstall the current managed targets.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Auto Update
|
||||
|
||||
Update ECC from its upstream repo and regenerate the current context's managed install using the original install-state request.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Preview the update without mutating anything
|
||||
ECC_ROOT="${CLAUDE_PLUGIN_ROOT:-$(node -e "var r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [['ecc'],['ecc@ecc'],['marketplace','ecc'],['everything-claude-code'],['everything-claude-code@everything-claude-code'],['marketplace','everything-claude-code']]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of ['ecc','everything-claude-code']){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();console.log(r)")}"
|
||||
node "$ECC_ROOT/scripts/auto-update.js" --dry-run
|
||||
|
||||
# Update only Cursor-managed files in the current project
|
||||
node "$ECC_ROOT/scripts/auto-update.js" --target cursor
|
||||
|
||||
# Override the ECC repo root explicitly
|
||||
node "$ECC_ROOT/scripts/auto-update.js" --repo-root /path/to/everything-claude-code
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- This command uses the recorded install-state request and reruns `install-apply.js` after pulling the latest repo changes.
|
||||
- Reinstall is intentional: it handles upstream renames and deletions that `repair.js` cannot safely reconstruct from stale operations alone.
|
||||
- Use `--dry-run` first if you want to see the reconstructed reinstall plan before mutating anything.
|
||||
+10
-16
@@ -29,9 +29,8 @@ Use `/sessions info` when you need operator-surface context for a swarm: branch,
|
||||
**Script:**
|
||||
```bash
|
||||
node -e "
|
||||
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 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 path = require('path');
|
||||
|
||||
const result = sm.getAllSessions({ limit: 20 });
|
||||
@@ -71,9 +70,8 @@ Load and display a session's content (by ID or alias).
|
||||
**Script:**
|
||||
```bash
|
||||
node -e "
|
||||
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 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 id = process.argv[1];
|
||||
|
||||
// First try to resolve as alias
|
||||
@@ -145,9 +143,8 @@ Create a memorable alias for a session.
|
||||
**Script:**
|
||||
```bash
|
||||
node -e "
|
||||
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 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 sessionId = process.argv[1];
|
||||
const aliasName = process.argv[2];
|
||||
@@ -186,8 +183,7 @@ Delete an existing alias.
|
||||
**Script:**
|
||||
```bash
|
||||
node -e "
|
||||
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 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 aliasName = process.argv[1];
|
||||
if (!aliasName) {
|
||||
@@ -216,9 +212,8 @@ Show detailed information about a session.
|
||||
**Script:**
|
||||
```bash
|
||||
node -e "
|
||||
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 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 id = process.argv[1];
|
||||
const resolved = aa.resolveAlias(id);
|
||||
@@ -267,8 +262,7 @@ Show all session aliases.
|
||||
**Script:**
|
||||
```bash
|
||||
node -e "
|
||||
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 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 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 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)")}"
|
||||
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)")}"
|
||||
node "$ECC_ROOT/scripts/skills-health.js" --dashboard
|
||||
```
|
||||
|
||||
For a specific panel only:
|
||||
|
||||
```bash
|
||||
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)")}"
|
||||
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)")}"
|
||||
node "$ECC_ROOT/scripts/skills-health.js" --dashboard --panel failures
|
||||
```
|
||||
|
||||
For machine-readable output:
|
||||
|
||||
```bash
|
||||
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)")}"
|
||||
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)")}"
|
||||
node "$ECC_ROOT/scripts/skills-health.js" --dashboard --json
|
||||
```
|
||||
|
||||
|
||||
@@ -183,21 +183,6 @@ It is mostly:
|
||||
- clarifying public docs
|
||||
- continuing the ECC 2.0 operator/control-plane buildout
|
||||
|
||||
ECC 2.0 now ships a bounded migration audit entrypoint:
|
||||
|
||||
- `ecc migrate audit --source ~/.hermes`
|
||||
- `ecc migrate plan --source ~/.hermes --output migration-plan.md`
|
||||
- `ecc migrate scaffold --source ~/.hermes --output-dir migration-artifacts`
|
||||
- `ecc migrate import-skills --source ~/.hermes --output-dir migration-artifacts/skills`
|
||||
- `ecc migrate import-tools --source ~/.hermes --output-dir migration-artifacts/tools`
|
||||
- `ecc migrate import-plugins --source ~/.hermes --output-dir migration-artifacts/plugins`
|
||||
- `ecc migrate import-schedules --source ~/.hermes --dry-run`
|
||||
- `ecc migrate import-remote --source ~/.hermes --dry-run`
|
||||
- `ecc migrate import-env --source ~/.hermes --dry-run`
|
||||
- `ecc migrate import-memory --source ~/.hermes`
|
||||
|
||||
Use that first to inventory the legacy workspace and map detected surfaces onto the current ECC2 scheduler, remote dispatch, memory graph, templates, and manual-translation lanes.
|
||||
|
||||
## What Still Belongs In Backlog
|
||||
|
||||
The remaining large migration themes are already tracked:
|
||||
|
||||
@@ -82,8 +82,6 @@ These stay local and should be configured per operator:
|
||||
|
||||
## Suggested Bring-Up Order
|
||||
|
||||
0. Run `ecc migrate audit --source ~/.hermes` first to inventory the legacy workspace and see which parts already map onto ECC2.
|
||||
0.5. Generate and review artifacts with `ecc migrate plan` / `ecc migrate scaffold`, scaffold reusable legacy skills with `ecc migrate import-skills --output-dir migration-artifacts/skills`, scaffold legacy tool translation templates with `ecc migrate import-tools --output-dir migration-artifacts/tools`, scaffold legacy bridge plugins with `ecc migrate import-plugins --output-dir migration-artifacts/plugins`, preview recurring jobs with `ecc migrate import-schedules --dry-run`, preview gateway dispatch with `ecc migrate import-remote --dry-run`, preview safe env/service context with `ecc migrate import-env --dry-run`, then import sanitized workspace memory with `ecc migrate import-memory`.
|
||||
1. Install ECC and verify the baseline harness setup.
|
||||
2. Install Hermes and point it at ECC-imported skills.
|
||||
3. Register the MCP servers you actually use every day.
|
||||
|
||||
@@ -497,9 +497,7 @@ cp -r everything-claude-code/skills/* ~/.claude/skills/
|
||||
|
||||
#### settings.json にフックを追加
|
||||
|
||||
手動インストール時のみ、`hooks/hooks.json` のフックを `~/.claude/settings.json` にコピーします。
|
||||
|
||||
`/plugin install` で ECC を導入した場合は、これらのフックを `settings.json` にコピーしないでください。Claude Code v2.1+ はプラグインの `hooks/hooks.json` を自動読み込みするため、二重登録すると重複実行や `${CLAUDE_PLUGIN_ROOT}` の解決失敗が発生します。
|
||||
`hooks/hooks.json` のフックを `~/.claude/settings.json` にコピーします。
|
||||
|
||||
#### MCP を設定
|
||||
|
||||
|
||||
@@ -80,15 +80,6 @@ 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,15 +79,6 @@ 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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Everything Claude Code (ECC) — 智能体指令
|
||||
|
||||
这是一个**生产就绪的 AI 编码插件**,提供 47 个专业代理、181 项技能、80 条命令以及自动化钩子工作流,用于软件开发。
|
||||
这是一个**生产就绪的 AI 编码插件**,提供 47 个专业代理、181 项技能、79 条命令以及自动化钩子工作流,用于软件开发。
|
||||
|
||||
**版本:** 1.10.0
|
||||
|
||||
@@ -148,7 +148,7 @@
|
||||
```
|
||||
agents/ — 47 个专业子代理
|
||||
skills/ — 181 个工作流技能和领域知识
|
||||
commands/ — 80 个斜杠命令
|
||||
commands/ — 79 个斜杠命令
|
||||
hooks/ — 基于触发的自动化
|
||||
rules/ — 始终遵循的指导方针(通用 + 每种语言)
|
||||
scripts/ — 跨平台 Node.js 实用工具
|
||||
|
||||
@@ -209,7 +209,7 @@ npx ecc-install typescript
|
||||
/plugin list ecc@ecc
|
||||
```
|
||||
|
||||
**搞定!** 你现在可以使用 47 个智能体、181 项技能和 80 个命令了。
|
||||
**搞定!** 你现在可以使用 47 个智能体、181 项技能和 79 个命令了。
|
||||
|
||||
***
|
||||
|
||||
@@ -1095,7 +1095,7 @@ opencode
|
||||
| 功能特性 | Claude Code | OpenCode | 状态 |
|
||||
|---------|-------------|----------|--------|
|
||||
| 智能体 | PASS: 47 个 | PASS: 12 个 | **Claude Code 领先** |
|
||||
| 命令 | PASS: 80 个 | PASS: 31 个 | **Claude Code 领先** |
|
||||
| 命令 | PASS: 79 个 | PASS: 31 个 | **Claude Code 领先** |
|
||||
| 技能 | PASS: 181 项 | PASS: 37 项 | **Claude Code 领先** |
|
||||
| 钩子 | PASS: 8 种事件类型 | PASS: 11 种事件 | **OpenCode 更多!** |
|
||||
| 规则 | PASS: 29 条 | PASS: 13 条指令 | **Claude Code 领先** |
|
||||
@@ -1207,7 +1207,7 @@ ECC 是**第一个最大化利用每个主要 AI 编码工具的插件**。以
|
||||
| 功能特性 | Claude Code | Cursor IDE | Codex CLI | OpenCode |
|
||||
|---------|------------|------------|-----------|----------|
|
||||
| **智能体** | 47 | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 |
|
||||
| **命令** | 80 | 共享 | 基于指令 | 31 |
|
||||
| **命令** | 79 | 共享 | 基于指令 | 31 |
|
||||
| **技能** | 181 | 共享 | 10 (原生格式) | 37 |
|
||||
| **钩子事件** | 8 种类型 | 15 种类型 | 暂无 | 11 种类型 |
|
||||
| **钩子脚本** | 20+ 个脚本 | 16 个脚本 (DRY 适配器) | N/A | 插件钩子 |
|
||||
|
||||
@@ -318,9 +318,7 @@ cp -r everything-claude-code/skills/* ~/.claude/skills/
|
||||
|
||||
#### 將鉤子新增到 settings.json
|
||||
|
||||
僅在手動安裝時,才將 `hooks/hooks.json` 中的鉤子複製到您的 `~/.claude/settings.json`。
|
||||
|
||||
如果您是透過 `/plugin install` 安裝 ECC,請不要再把這些鉤子複製到 `settings.json`。Claude Code v2.1+ 會自動載入外掛中的 `hooks/hooks.json`,重複註冊會導致重複執行以及 `${CLAUDE_PLUGIN_ROOT}` 無法解析。
|
||||
將 `hooks/hooks.json` 中的鉤子複製到您的 `~/.claude/settings.json`。
|
||||
|
||||
#### 設定 MCP
|
||||
|
||||
|
||||
Generated
-168
@@ -2,12 +2,6 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
@@ -306,26 +300,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cron"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f8c3e73077b4b4a6ab1ea5047c37c57aee77657bc8ecd6f29b0af082d0b0c07"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"nom",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossterm"
|
||||
version = "0.28.1"
|
||||
@@ -518,23 +492,19 @@ dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"clap",
|
||||
"cron",
|
||||
"crossterm 0.28.1",
|
||||
"dirs",
|
||||
"git2",
|
||||
"libc",
|
||||
"ratatui",
|
||||
"regex",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"toml",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"ureq",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -620,16 +590,6 @@ version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
@@ -1179,16 +1139,6 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.1.1"
|
||||
@@ -1660,20 +1610,6 @@ version = "0.8.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.32.1"
|
||||
@@ -1723,41 +1659,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
@@ -1891,12 +1792,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.2"
|
||||
@@ -1958,12 +1853,6 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.109"
|
||||
@@ -2317,30 +2206,6 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "ureq"
|
||||
version = "2.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"flate2",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"url",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -2507,24 +2372,6 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wezterm-bidi"
|
||||
version = "0.2.3"
|
||||
@@ -2678,15 +2525,6 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
@@ -2936,12 +2774,6 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.3"
|
||||
|
||||
@@ -25,9 +25,6 @@ git2 = "0.20"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "0.8"
|
||||
regex = "1"
|
||||
sha2 = "0.10"
|
||||
ureq = { version = "2", features = ["json"] }
|
||||
|
||||
# CLI
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
@@ -43,7 +40,6 @@ libc = "0.2"
|
||||
|
||||
# Time
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
cron = "0.12"
|
||||
|
||||
# UUID for session IDs
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
+2
-72
@@ -1,41 +1,13 @@
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
||||
use crate::session::store::StateStore;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TaskPriority {
|
||||
Low,
|
||||
#[default]
|
||||
Normal,
|
||||
High,
|
||||
Critical,
|
||||
}
|
||||
|
||||
impl fmt::Display for TaskPriority {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let label = match self {
|
||||
Self::Low => "low",
|
||||
Self::Normal => "normal",
|
||||
Self::High => "high",
|
||||
Self::Critical => "critical",
|
||||
};
|
||||
write!(f, "{label}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Message types for inter-agent communication.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum MessageType {
|
||||
/// Task handoff from one agent to another
|
||||
TaskHandoff {
|
||||
task: String,
|
||||
context: String,
|
||||
#[serde(default)]
|
||||
priority: TaskPriority,
|
||||
},
|
||||
TaskHandoff { task: String, context: String },
|
||||
/// Agent requesting information from another
|
||||
Query { question: String },
|
||||
/// Response to a query
|
||||
@@ -74,16 +46,7 @@ pub fn parse(content: &str) -> Option<MessageType> {
|
||||
pub fn preview(msg_type: &str, content: &str) -> String {
|
||||
match parse(content) {
|
||||
Some(MessageType::TaskHandoff { task, .. }) => {
|
||||
let priority = handoff_priority(content);
|
||||
if priority == TaskPriority::Normal {
|
||||
format!("handoff {}", truncate(&task, 56))
|
||||
} else {
|
||||
format!(
|
||||
"handoff [{}] {}",
|
||||
priority_label(priority),
|
||||
truncate(&task, 48)
|
||||
)
|
||||
}
|
||||
format!("handoff {}", truncate(&task, 56))
|
||||
}
|
||||
Some(MessageType::Query { question }) => {
|
||||
format!("query {}", truncate(&question, 56))
|
||||
@@ -112,39 +75,6 @@ pub fn preview(msg_type: &str, content: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handoff_priority(content: &str) -> TaskPriority {
|
||||
match parse(content) {
|
||||
Some(MessageType::TaskHandoff { priority, .. }) => priority,
|
||||
_ => extract_legacy_handoff_priority(content),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_legacy_handoff_priority(content: &str) -> TaskPriority {
|
||||
let value: serde_json::Value = match serde_json::from_str(content) {
|
||||
Ok(value) => value,
|
||||
Err(_) => return TaskPriority::Normal,
|
||||
};
|
||||
match value
|
||||
.get("priority")
|
||||
.and_then(|priority| priority.as_str())
|
||||
.unwrap_or("normal")
|
||||
{
|
||||
"low" => TaskPriority::Low,
|
||||
"high" => TaskPriority::High,
|
||||
"critical" => TaskPriority::Critical,
|
||||
_ => TaskPriority::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
fn priority_label(priority: TaskPriority) -> &'static str {
|
||||
match priority {
|
||||
TaskPriority::Low => "low",
|
||||
TaskPriority::Normal => "normal",
|
||||
TaskPriority::High => "high",
|
||||
TaskPriority::Critical => "critical",
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate(value: &str, max_chars: usize) -> String {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.chars().count() <= max_chars {
|
||||
|
||||
+10
-1611
File diff suppressed because it is too large
Load Diff
+61
-9882
File diff suppressed because it is too large
Load Diff
@@ -1,635 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Local, Timelike};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
#[cfg(not(test))]
|
||||
use anyhow::Context;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NotificationEvent {
|
||||
SessionStarted,
|
||||
SessionCompleted,
|
||||
SessionFailed,
|
||||
BudgetAlert,
|
||||
ApprovalRequest,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct QuietHoursConfig {
|
||||
pub enabled: bool,
|
||||
pub start_hour: u8,
|
||||
pub end_hour: u8,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct DesktopNotificationConfig {
|
||||
pub enabled: bool,
|
||||
pub session_started: bool,
|
||||
pub session_completed: bool,
|
||||
pub session_failed: bool,
|
||||
pub budget_alerts: bool,
|
||||
pub approval_requests: bool,
|
||||
pub quiet_hours: QuietHoursConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CompletionSummaryDelivery {
|
||||
#[default]
|
||||
Desktop,
|
||||
TuiPopup,
|
||||
DesktopAndTuiPopup,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct CompletionSummaryConfig {
|
||||
pub enabled: bool,
|
||||
pub delivery: CompletionSummaryDelivery,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WebhookProvider {
|
||||
#[default]
|
||||
Slack,
|
||||
Discord,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct WebhookTarget {
|
||||
pub provider: WebhookProvider,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct WebhookNotificationConfig {
|
||||
pub enabled: bool,
|
||||
pub session_started: bool,
|
||||
pub session_completed: bool,
|
||||
pub session_failed: bool,
|
||||
pub budget_alerts: bool,
|
||||
pub approval_requests: bool,
|
||||
pub targets: Vec<WebhookTarget>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DesktopNotifier {
|
||||
config: DesktopNotificationConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WebhookNotifier {
|
||||
config: WebhookNotificationConfig,
|
||||
}
|
||||
|
||||
impl Default for QuietHoursConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
start_hour: 22,
|
||||
end_hour: 8,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl QuietHoursConfig {
|
||||
pub fn sanitized(self) -> Self {
|
||||
let valid = self.start_hour <= 23 && self.end_hour <= 23;
|
||||
if valid {
|
||||
self
|
||||
} else {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_active(&self, now: DateTime<Local>) -> bool {
|
||||
if !self.enabled {
|
||||
return false;
|
||||
}
|
||||
|
||||
let quiet = self.clone().sanitized();
|
||||
if quiet.start_hour == quiet.end_hour {
|
||||
return false;
|
||||
}
|
||||
|
||||
let hour = now.hour() as u8;
|
||||
if quiet.start_hour < quiet.end_hour {
|
||||
hour >= quiet.start_hour && hour < quiet.end_hour
|
||||
} else {
|
||||
hour >= quiet.start_hour || hour < quiet.end_hour
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DesktopNotificationConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
session_started: false,
|
||||
session_completed: true,
|
||||
session_failed: true,
|
||||
budget_alerts: true,
|
||||
approval_requests: true,
|
||||
quiet_hours: QuietHoursConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DesktopNotificationConfig {
|
||||
pub fn sanitized(self) -> Self {
|
||||
Self {
|
||||
quiet_hours: self.quiet_hours.sanitized(),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn allows(&self, event: NotificationEvent, now: DateTime<Local>) -> bool {
|
||||
let config = self.clone().sanitized();
|
||||
if !config.enabled || config.quiet_hours.is_active(now) {
|
||||
return false;
|
||||
}
|
||||
|
||||
match event {
|
||||
NotificationEvent::SessionStarted => config.session_started,
|
||||
NotificationEvent::SessionCompleted => config.session_completed,
|
||||
NotificationEvent::SessionFailed => config.session_failed,
|
||||
NotificationEvent::BudgetAlert => config.budget_alerts,
|
||||
NotificationEvent::ApprovalRequest => config.approval_requests,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CompletionSummaryConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
delivery: CompletionSummaryDelivery::Desktop,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CompletionSummaryConfig {
|
||||
pub fn desktop_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
&& matches!(
|
||||
self.delivery,
|
||||
CompletionSummaryDelivery::Desktop | CompletionSummaryDelivery::DesktopAndTuiPopup
|
||||
)
|
||||
}
|
||||
|
||||
pub fn popup_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
&& matches!(
|
||||
self.delivery,
|
||||
CompletionSummaryDelivery::TuiPopup | CompletionSummaryDelivery::DesktopAndTuiPopup
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WebhookTarget {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
provider: WebhookProvider::Slack,
|
||||
url: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WebhookTarget {
|
||||
fn sanitized(self) -> Option<Self> {
|
||||
let url = self.url.trim().to_string();
|
||||
if url.starts_with("https://") || url.starts_with("http://") {
|
||||
Some(Self { url, ..self })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WebhookNotificationConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
session_started: true,
|
||||
session_completed: true,
|
||||
session_failed: true,
|
||||
budget_alerts: true,
|
||||
approval_requests: false,
|
||||
targets: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WebhookNotificationConfig {
|
||||
pub fn sanitized(self) -> Self {
|
||||
Self {
|
||||
targets: self
|
||||
.targets
|
||||
.into_iter()
|
||||
.filter_map(WebhookTarget::sanitized)
|
||||
.collect(),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn allows(&self, event: NotificationEvent) -> bool {
|
||||
let config = self.clone().sanitized();
|
||||
if !config.enabled || config.targets.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
match event {
|
||||
NotificationEvent::SessionStarted => config.session_started,
|
||||
NotificationEvent::SessionCompleted => config.session_completed,
|
||||
NotificationEvent::SessionFailed => config.session_failed,
|
||||
NotificationEvent::BudgetAlert => config.budget_alerts,
|
||||
NotificationEvent::ApprovalRequest => config.approval_requests,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DesktopNotifier {
|
||||
pub fn new(config: DesktopNotificationConfig) -> Self {
|
||||
Self {
|
||||
config: config.sanitized(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn notify(&self, event: NotificationEvent, title: &str, body: &str) -> bool {
|
||||
match self.try_notify(event, title, body, Local::now()) {
|
||||
Ok(sent) => sent,
|
||||
Err(error) => {
|
||||
tracing::warn!("Failed to send desktop notification: {error}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_notify(
|
||||
&self,
|
||||
event: NotificationEvent,
|
||||
title: &str,
|
||||
body: &str,
|
||||
now: DateTime<Local>,
|
||||
) -> Result<bool> {
|
||||
if !self.config.allows(event, now) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let Some((program, args)) = notification_command(std::env::consts::OS, title, body) else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
run_notification_command(&program, &args)?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
impl WebhookNotifier {
|
||||
pub fn new(config: WebhookNotificationConfig) -> Self {
|
||||
Self {
|
||||
config: config.sanitized(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn notify(&self, event: NotificationEvent, message: &str) -> bool {
|
||||
match self.try_notify(event, message) {
|
||||
Ok(sent) => sent,
|
||||
Err(error) => {
|
||||
tracing::warn!("Failed to send webhook notification: {error}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_notify(&self, event: NotificationEvent, message: &str) -> Result<bool> {
|
||||
self.try_notify_with(event, message, send_webhook_request)
|
||||
}
|
||||
|
||||
fn try_notify_with<F>(
|
||||
&self,
|
||||
event: NotificationEvent,
|
||||
message: &str,
|
||||
mut sender: F,
|
||||
) -> Result<bool>
|
||||
where
|
||||
F: FnMut(&WebhookTarget, serde_json::Value) -> Result<()>,
|
||||
{
|
||||
if !self.config.allows(event) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut delivered = false;
|
||||
for target in &self.config.targets {
|
||||
let payload = webhook_payload(target, message);
|
||||
match sender(target, payload) {
|
||||
Ok(()) => delivered = true,
|
||||
Err(error) => tracing::warn!(
|
||||
"Failed to deliver {:?} webhook notification to {}: {error}",
|
||||
target.provider,
|
||||
target.url
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(delivered)
|
||||
}
|
||||
}
|
||||
|
||||
fn notification_command(platform: &str, title: &str, body: &str) -> Option<(String, Vec<String>)> {
|
||||
match platform {
|
||||
"macos" => Some((
|
||||
"osascript".to_string(),
|
||||
vec![
|
||||
"-e".to_string(),
|
||||
format!(
|
||||
"display notification \"{}\" with title \"{}\"",
|
||||
sanitize_osascript(body),
|
||||
sanitize_osascript(title)
|
||||
),
|
||||
],
|
||||
)),
|
||||
"linux" => Some((
|
||||
"notify-send".to_string(),
|
||||
vec![
|
||||
"--app-name".to_string(),
|
||||
"ECC 2.0".to_string(),
|
||||
title.trim().to_string(),
|
||||
body.trim().to_string(),
|
||||
],
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn webhook_payload(target: &WebhookTarget, message: &str) -> serde_json::Value {
|
||||
match target.provider {
|
||||
WebhookProvider::Slack => json!({
|
||||
"text": message,
|
||||
}),
|
||||
WebhookProvider::Discord => json!({
|
||||
"content": message,
|
||||
"allowed_mentions": {
|
||||
"parse": []
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn run_notification_command(program: &str, args: &[String]) -> Result<()> {
|
||||
let status = std::process::Command::new(program)
|
||||
.args(args)
|
||||
.status()
|
||||
.with_context(|| format!("launch {program}"))?;
|
||||
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
anyhow::bail!("{program} exited with {status}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn run_notification_command(_program: &str, _args: &[String]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn send_webhook_request(target: &WebhookTarget, payload: serde_json::Value) -> Result<()> {
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout_connect(std::time::Duration::from_secs(5))
|
||||
.timeout_read(std::time::Duration::from_secs(5))
|
||||
.build();
|
||||
let response = agent
|
||||
.post(&target.url)
|
||||
.send_json(payload)
|
||||
.with_context(|| format!("POST {}", target.url))?;
|
||||
|
||||
if response.status() >= 200 && response.status() < 300 {
|
||||
Ok(())
|
||||
} else {
|
||||
anyhow::bail!("{} returned {}", target.url, response.status());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn send_webhook_request(_target: &WebhookTarget, _payload: serde_json::Value) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sanitize_osascript(value: &str) -> String {
|
||||
value
|
||||
.replace('\\', "")
|
||||
.replace('"', "\u{201C}")
|
||||
.replace('\n', " ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
notification_command, webhook_payload, CompletionSummaryDelivery,
|
||||
DesktopNotificationConfig, DesktopNotifier, NotificationEvent, QuietHoursConfig,
|
||||
WebhookNotificationConfig, WebhookNotifier, WebhookProvider, WebhookTarget,
|
||||
};
|
||||
use chrono::{Local, TimeZone};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn quiet_hours_support_cross_midnight_ranges() {
|
||||
let quiet_hours = QuietHoursConfig {
|
||||
enabled: true,
|
||||
start_hour: 22,
|
||||
end_hour: 8,
|
||||
};
|
||||
|
||||
assert!(quiet_hours.is_active(Local.with_ymd_and_hms(2026, 4, 9, 23, 0, 0).unwrap()));
|
||||
assert!(quiet_hours.is_active(Local.with_ymd_and_hms(2026, 4, 9, 7, 0, 0).unwrap()));
|
||||
assert!(!quiet_hours.is_active(Local.with_ymd_and_hms(2026, 4, 9, 14, 0, 0).unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quiet_hours_support_same_day_ranges() {
|
||||
let quiet_hours = QuietHoursConfig {
|
||||
enabled: true,
|
||||
start_hour: 9,
|
||||
end_hour: 17,
|
||||
};
|
||||
|
||||
assert!(quiet_hours.is_active(Local.with_ymd_and_hms(2026, 4, 9, 10, 0, 0).unwrap()));
|
||||
assert!(!quiet_hours.is_active(Local.with_ymd_and_hms(2026, 4, 9, 18, 0, 0).unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_preferences_respect_event_flags() {
|
||||
let mut config = DesktopNotificationConfig::default();
|
||||
config.session_completed = false;
|
||||
let now = Local.with_ymd_and_hms(2026, 4, 9, 12, 0, 0).unwrap();
|
||||
|
||||
assert!(!config.allows(NotificationEvent::SessionCompleted, now));
|
||||
assert!(config.allows(NotificationEvent::BudgetAlert, now));
|
||||
assert!(!config.allows(NotificationEvent::SessionStarted, now));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notifier_skips_delivery_during_quiet_hours() {
|
||||
let mut config = DesktopNotificationConfig::default();
|
||||
config.quiet_hours = QuietHoursConfig {
|
||||
enabled: true,
|
||||
start_hour: 22,
|
||||
end_hour: 8,
|
||||
};
|
||||
let notifier = DesktopNotifier::new(config);
|
||||
|
||||
assert!(!notifier
|
||||
.try_notify(
|
||||
NotificationEvent::ApprovalRequest,
|
||||
"ECC 2.0: Approval needed",
|
||||
"worker-123 needs review",
|
||||
Local.with_ymd_and_hms(2026, 4, 9, 23, 0, 0).unwrap(),
|
||||
)
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_notifications_use_osascript() {
|
||||
let (program, args) =
|
||||
notification_command("macos", "ECC 2.0: Completed", "Task finished").unwrap();
|
||||
|
||||
assert_eq!(program, "osascript");
|
||||
assert_eq!(args[0], "-e");
|
||||
assert!(args[1].contains("display notification"));
|
||||
assert!(args[1].contains("ECC 2.0: Completed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_notifications_use_notify_send() {
|
||||
let (program, args) =
|
||||
notification_command("linux", "ECC 2.0: Approval needed", "worker-123").unwrap();
|
||||
|
||||
assert_eq!(program, "notify-send");
|
||||
assert_eq!(args[0], "--app-name");
|
||||
assert_eq!(args[1], "ECC 2.0");
|
||||
assert_eq!(args[2], "ECC 2.0: Approval needed");
|
||||
assert_eq!(args[3], "worker-123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webhook_notifications_require_enabled_targets_and_event() {
|
||||
let mut config = WebhookNotificationConfig::default();
|
||||
assert!(!config.allows(NotificationEvent::SessionCompleted));
|
||||
|
||||
config.enabled = true;
|
||||
config.targets = vec![WebhookTarget {
|
||||
provider: WebhookProvider::Slack,
|
||||
url: "https://hooks.slack.test/services/abc".to_string(),
|
||||
}];
|
||||
|
||||
assert!(config.allows(NotificationEvent::SessionCompleted));
|
||||
assert!(config.allows(NotificationEvent::SessionStarted));
|
||||
assert!(!config.allows(NotificationEvent::ApprovalRequest));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webhook_sanitization_filters_invalid_urls() {
|
||||
let config = WebhookNotificationConfig {
|
||||
enabled: true,
|
||||
targets: vec![
|
||||
WebhookTarget {
|
||||
provider: WebhookProvider::Slack,
|
||||
url: "https://hooks.slack.test/services/abc".to_string(),
|
||||
},
|
||||
WebhookTarget {
|
||||
provider: WebhookProvider::Discord,
|
||||
url: "ftp://discord.invalid".to_string(),
|
||||
},
|
||||
],
|
||||
..WebhookNotificationConfig::default()
|
||||
}
|
||||
.sanitized();
|
||||
|
||||
assert_eq!(config.targets.len(), 1);
|
||||
assert_eq!(config.targets[0].provider, WebhookProvider::Slack);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slack_webhook_payload_uses_text() {
|
||||
let payload = webhook_payload(
|
||||
&WebhookTarget {
|
||||
provider: WebhookProvider::Slack,
|
||||
url: "https://hooks.slack.test/services/abc".to_string(),
|
||||
},
|
||||
"*ECC 2.0* hello",
|
||||
);
|
||||
|
||||
assert_eq!(payload, json!({ "text": "*ECC 2.0* hello" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discord_webhook_payload_disables_mentions() {
|
||||
let payload = webhook_payload(
|
||||
&WebhookTarget {
|
||||
provider: WebhookProvider::Discord,
|
||||
url: "https://discord.test/api/webhooks/123".to_string(),
|
||||
},
|
||||
"```text\nsummary\n```",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
payload,
|
||||
json!({
|
||||
"content": "```text\nsummary\n```",
|
||||
"allowed_mentions": { "parse": [] }
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webhook_notifier_sends_to_each_target() {
|
||||
let notifier = WebhookNotifier::new(WebhookNotificationConfig {
|
||||
enabled: true,
|
||||
targets: vec![
|
||||
WebhookTarget {
|
||||
provider: WebhookProvider::Slack,
|
||||
url: "https://hooks.slack.test/services/abc".to_string(),
|
||||
},
|
||||
WebhookTarget {
|
||||
provider: WebhookProvider::Discord,
|
||||
url: "https://discord.test/api/webhooks/123".to_string(),
|
||||
},
|
||||
],
|
||||
..WebhookNotificationConfig::default()
|
||||
});
|
||||
let mut sent = Vec::new();
|
||||
|
||||
let delivered = notifier
|
||||
.try_notify_with(
|
||||
NotificationEvent::SessionCompleted,
|
||||
"payload text",
|
||||
|target, payload| {
|
||||
sent.push((target.provider, payload));
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(delivered);
|
||||
assert_eq!(sent.len(), 2);
|
||||
assert_eq!(sent[0].0, WebhookProvider::Slack);
|
||||
assert_eq!(sent[1].0, WebhookProvider::Discord);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_summary_delivery_defaults_to_desktop() {
|
||||
assert_eq!(
|
||||
CompletionSummaryDelivery::default(),
|
||||
CompletionSummaryDelivery::Desktop
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,7 @@ pub struct ToolCallEvent {
|
||||
pub session_id: String,
|
||||
pub tool_name: String,
|
||||
pub input_summary: String,
|
||||
pub input_params_json: String,
|
||||
pub output_summary: String,
|
||||
pub trigger_summary: String,
|
||||
pub duration_ms: u64,
|
||||
pub risk_score: f64,
|
||||
}
|
||||
@@ -49,9 +47,7 @@ impl ToolCallEvent {
|
||||
.score,
|
||||
tool_name,
|
||||
input_summary,
|
||||
input_params_json: "{}".to_string(),
|
||||
output_summary: output_summary.into(),
|
||||
trigger_summary: String::new(),
|
||||
duration_ms,
|
||||
}
|
||||
}
|
||||
@@ -242,9 +238,7 @@ pub struct ToolLogEntry {
|
||||
pub session_id: String,
|
||||
pub tool_name: String,
|
||||
pub input_summary: String,
|
||||
pub input_params_json: String,
|
||||
pub output_summary: String,
|
||||
pub trigger_summary: String,
|
||||
pub duration_ms: u64,
|
||||
pub risk_score: f64,
|
||||
pub timestamp: String,
|
||||
@@ -274,9 +268,7 @@ impl<'a> ToolLogger<'a> {
|
||||
&event.session_id,
|
||||
&event.tool_name,
|
||||
&event.input_summary,
|
||||
&event.input_params_json,
|
||||
&event.output_summary,
|
||||
&event.trigger_summary,
|
||||
event.duration_ms,
|
||||
event.risk_score,
|
||||
×tamp,
|
||||
@@ -314,8 +306,6 @@ mod tests {
|
||||
Session {
|
||||
id: id.to_string(),
|
||||
task: "test task".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: PathBuf::from("/tmp"),
|
||||
state: SessionState::Pending,
|
||||
@@ -323,7 +313,6 @@ mod tests {
|
||||
worktree: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
last_heartbeat_at: now,
|
||||
metrics: SessionMetrics::default(),
|
||||
}
|
||||
}
|
||||
@@ -408,8 +397,6 @@ mod tests {
|
||||
assert_eq!(first_page.entries.len(), 2);
|
||||
assert_eq!(first_page.entries[0].tool_name, "Bash");
|
||||
assert_eq!(first_page.entries[1].tool_name, "Write");
|
||||
assert_eq!(first_page.entries[0].input_params_json, "{}");
|
||||
assert_eq!(first_page.entries[0].trigger_summary, "");
|
||||
|
||||
let second_page = logger.query("sess-1", 2, 2)?;
|
||||
assert_eq!(second_page.total, 3);
|
||||
|
||||
+25
-62
@@ -22,19 +22,13 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> {
|
||||
resume_crashed_sessions(&db)?;
|
||||
|
||||
let heartbeat_interval = Duration::from_secs(cfg.heartbeat_interval_secs);
|
||||
let timeout = Duration::from_secs(cfg.session_timeout_secs);
|
||||
|
||||
loop {
|
||||
if let Err(e) = check_sessions(&db, &cfg) {
|
||||
if let Err(e) = check_sessions(&db, timeout) {
|
||||
tracing::error!("Session check failed: {e}");
|
||||
}
|
||||
|
||||
if let Err(e) = maybe_run_due_schedules(&db, &cfg).await {
|
||||
tracing::error!("Scheduled task dispatch pass failed: {e}");
|
||||
}
|
||||
|
||||
if let Err(e) = maybe_run_remote_dispatch(&db, &cfg).await {
|
||||
tracing::error!("Remote dispatch pass failed: {e}");
|
||||
}
|
||||
|
||||
if let Err(e) = coordinate_backlog_cycle(&db, &cfg).await {
|
||||
tracing::error!("Backlog coordination pass failed: {e}");
|
||||
}
|
||||
@@ -43,14 +37,10 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> {
|
||||
tracing::error!("Worktree auto-merge pass failed: {e}");
|
||||
}
|
||||
|
||||
if let Err(e) = maybe_auto_prune_inactive_worktrees(&db, &cfg).await {
|
||||
if let Err(e) = maybe_auto_prune_inactive_worktrees(&db).await {
|
||||
tracing::error!("Worktree auto-prune pass failed: {e}");
|
||||
}
|
||||
|
||||
if let Err(e) = manager::activate_pending_worktree_sessions(&db, &cfg).await {
|
||||
tracing::error!("Queued worktree activation pass failed: {e}");
|
||||
}
|
||||
|
||||
time::sleep(heartbeat_interval).await;
|
||||
}
|
||||
}
|
||||
@@ -92,38 +82,28 @@ where
|
||||
Ok(failed_sessions)
|
||||
}
|
||||
|
||||
fn check_sessions(db: &StateStore, cfg: &Config) -> Result<()> {
|
||||
let _ = manager::enforce_session_heartbeats(db, cfg)?;
|
||||
fn check_sessions(db: &StateStore, timeout: Duration) -> Result<()> {
|
||||
let sessions = db.list_sessions()?;
|
||||
|
||||
for session in sessions {
|
||||
if session.state != SessionState::Running {
|
||||
continue;
|
||||
}
|
||||
|
||||
let elapsed = chrono::Utc::now()
|
||||
.signed_duration_since(session.updated_at)
|
||||
.to_std()
|
||||
.unwrap_or(Duration::ZERO);
|
||||
|
||||
if elapsed > timeout {
|
||||
tracing::warn!("Session {} timed out after {:?}", session.id, elapsed);
|
||||
db.update_state_and_pid(&session.id, &SessionState::Failed, None)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn maybe_run_due_schedules(db: &StateStore, cfg: &Config) -> Result<usize> {
|
||||
let outcomes = manager::run_due_schedules(db, cfg, cfg.max_parallel_sessions).await?;
|
||||
if !outcomes.is_empty() {
|
||||
tracing::info!("Dispatched {} scheduled task(s)", outcomes.len());
|
||||
}
|
||||
Ok(outcomes.len())
|
||||
}
|
||||
|
||||
async fn maybe_run_remote_dispatch(db: &StateStore, cfg: &Config) -> Result<usize> {
|
||||
let outcomes =
|
||||
manager::run_remote_dispatch_requests(db, cfg, cfg.max_parallel_sessions).await?;
|
||||
let routed = outcomes
|
||||
.iter()
|
||||
.filter(|outcome| {
|
||||
matches!(
|
||||
outcome.action,
|
||||
manager::RemoteDispatchAction::SpawnedTopLevel
|
||||
| manager::RemoteDispatchAction::Assigned(_)
|
||||
)
|
||||
})
|
||||
.count();
|
||||
if routed > 0 {
|
||||
tracing::info!("Dispatched {} remote request(s)", routed);
|
||||
}
|
||||
Ok(routed)
|
||||
}
|
||||
|
||||
async fn maybe_auto_dispatch(db: &StateStore, cfg: &Config) -> Result<usize> {
|
||||
let summary = maybe_auto_dispatch_with_recorder(
|
||||
cfg,
|
||||
@@ -428,9 +408,9 @@ where
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
async fn maybe_auto_prune_inactive_worktrees(db: &StateStore, cfg: &Config) -> Result<usize> {
|
||||
async fn maybe_auto_prune_inactive_worktrees(db: &StateStore) -> Result<usize> {
|
||||
maybe_auto_prune_inactive_worktrees_with_recorder(
|
||||
|| manager::prune_inactive_worktrees(db, cfg),
|
||||
|| manager::prune_inactive_worktrees(db),
|
||||
|pruned, active| db.record_daemon_auto_prune_pass(pruned, active),
|
||||
)
|
||||
.await
|
||||
@@ -456,7 +436,6 @@ where
|
||||
let outcome = prune().await?;
|
||||
let pruned = outcome.cleaned_session_ids.len();
|
||||
let active = outcome.active_with_worktree_ids.len();
|
||||
let retained = outcome.retained_session_ids.len();
|
||||
record(pruned, active)?;
|
||||
|
||||
if pruned > 0 {
|
||||
@@ -465,9 +444,6 @@ where
|
||||
if active > 0 {
|
||||
tracing::info!("Skipped {active} active worktree(s) during auto-prune");
|
||||
}
|
||||
if retained > 0 {
|
||||
tracing::info!("Deferred {retained} inactive worktree(s) within retention");
|
||||
}
|
||||
|
||||
Ok(pruned)
|
||||
}
|
||||
@@ -515,8 +491,6 @@ mod tests {
|
||||
Session {
|
||||
id: id.to_string(),
|
||||
task: "Recover crashed worker".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: PathBuf::from("/tmp"),
|
||||
state,
|
||||
@@ -524,7 +498,6 @@ mod tests {
|
||||
worktree: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
last_heartbeat_at: now,
|
||||
metrics: SessionMetrics::default(),
|
||||
}
|
||||
}
|
||||
@@ -1237,11 +1210,9 @@ mod tests {
|
||||
invoked_flag.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
Ok(manager::WorktreeBulkMergeOutcome {
|
||||
merged: Vec::new(),
|
||||
rebased: Vec::new(),
|
||||
active_with_worktree_ids: Vec::new(),
|
||||
conflicted_session_ids: Vec::new(),
|
||||
dirty_worktree_ids: Vec::new(),
|
||||
blocked_by_queue_session_ids: Vec::new(),
|
||||
failures: Vec::new(),
|
||||
})
|
||||
}
|
||||
@@ -1276,16 +1247,9 @@ mod tests {
|
||||
cleaned_worktree: true,
|
||||
},
|
||||
],
|
||||
rebased: vec![manager::WorktreeRebaseOutcome {
|
||||
session_id: "worker-r".to_string(),
|
||||
branch: "ecc/worker-r".to_string(),
|
||||
base_branch: "main".to_string(),
|
||||
already_up_to_date: false,
|
||||
}],
|
||||
active_with_worktree_ids: vec!["worker-c".to_string()],
|
||||
conflicted_session_ids: vec!["worker-d".to_string()],
|
||||
dirty_worktree_ids: vec!["worker-e".to_string()],
|
||||
blocked_by_queue_session_ids: vec!["worker-f".to_string()],
|
||||
failures: Vec::new(),
|
||||
})
|
||||
})
|
||||
@@ -1305,7 +1269,6 @@ mod tests {
|
||||
Ok(manager::WorktreePruneOutcome {
|
||||
cleaned_session_ids: vec!["stopped-a".to_string(), "stopped-b".to_string()],
|
||||
active_with_worktree_ids: vec!["running-a".to_string()],
|
||||
retained_session_ids: vec!["retained-a".to_string()],
|
||||
})
|
||||
},
|
||||
move |pruned, active| {
|
||||
|
||||
+69
-5012
File diff suppressed because it is too large
Load Diff
+21
-849
@@ -6,307 +6,13 @@ pub mod store;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub type SessionAgentProfile = crate::config::ResolvedAgentProfile;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HarnessKind {
|
||||
#[default]
|
||||
Unknown,
|
||||
Claude,
|
||||
Codex,
|
||||
OpenCode,
|
||||
Gemini,
|
||||
Cursor,
|
||||
Kiro,
|
||||
Trae,
|
||||
Zed,
|
||||
FactoryDroid,
|
||||
Windsurf,
|
||||
}
|
||||
|
||||
impl HarnessKind {
|
||||
pub fn from_agent_type(agent_type: &str) -> Self {
|
||||
match agent_type.trim().to_ascii_lowercase().as_str() {
|
||||
"claude" | "claude-code" => Self::Claude,
|
||||
"codex" => Self::Codex,
|
||||
"opencode" => Self::OpenCode,
|
||||
"gemini" | "gemini-cli" => Self::Gemini,
|
||||
"cursor" => Self::Cursor,
|
||||
"kiro" => Self::Kiro,
|
||||
"trae" => Self::Trae,
|
||||
"zed" => Self::Zed,
|
||||
"factory-droid" | "factory_droid" | "factorydroid" => Self::FactoryDroid,
|
||||
"windsurf" => Self::Windsurf,
|
||||
_ => Self::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_db_value(value: &str) -> Self {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"claude" => Self::Claude,
|
||||
"codex" => Self::Codex,
|
||||
"opencode" => Self::OpenCode,
|
||||
"gemini" => Self::Gemini,
|
||||
"cursor" => Self::Cursor,
|
||||
"kiro" => Self::Kiro,
|
||||
"trae" => Self::Trae,
|
||||
"zed" => Self::Zed,
|
||||
"factory_droid" => Self::FactoryDroid,
|
||||
"windsurf" => Self::Windsurf,
|
||||
_ => Self::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Unknown => "unknown",
|
||||
Self::Claude => "claude",
|
||||
Self::Codex => "codex",
|
||||
Self::OpenCode => "opencode",
|
||||
Self::Gemini => "gemini",
|
||||
Self::Cursor => "cursor",
|
||||
Self::Kiro => "kiro",
|
||||
Self::Trae => "trae",
|
||||
Self::Zed => "zed",
|
||||
Self::FactoryDroid => "factory_droid",
|
||||
Self::Windsurf => "windsurf",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn canonical_agent_type(agent_type: &str) -> String {
|
||||
match Self::from_agent_type(agent_type) {
|
||||
Self::Unknown => agent_type.trim().to_ascii_lowercase(),
|
||||
harness => harness.as_str().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn supports_direct_execution(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Claude | Self::Codex | Self::OpenCode | Self::Gemini
|
||||
)
|
||||
}
|
||||
|
||||
fn project_markers(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Self::Claude => &[".claude"],
|
||||
Self::Codex => &[".codex", ".codex-plugin"],
|
||||
Self::OpenCode => &[".opencode"],
|
||||
Self::Gemini => &[".gemini"],
|
||||
Self::Cursor => &[".cursor"],
|
||||
Self::Kiro => &[".kiro"],
|
||||
Self::Trae => &[".trae"],
|
||||
Self::Zed => &[".zed"],
|
||||
Self::FactoryDroid => &[".factory-droid", ".factory_droid"],
|
||||
Self::Windsurf => &[".windsurf"],
|
||||
Self::Unknown => &[],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for HarnessKind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SessionHarnessInfo {
|
||||
pub primary: HarnessKind,
|
||||
pub primary_label: String,
|
||||
pub detected: Vec<HarnessKind>,
|
||||
pub detected_labels: Vec<String>,
|
||||
}
|
||||
|
||||
impl SessionHarnessInfo {
|
||||
fn detected_labels_for(detected: &[HarnessKind]) -> Vec<String> {
|
||||
detected.iter().map(|harness| harness.to_string()).collect()
|
||||
}
|
||||
|
||||
fn configured_detected_labels(cfg: &crate::config::Config, working_dir: &Path) -> Vec<String> {
|
||||
let mut labels = Vec::new();
|
||||
for (name, runner) in &cfg.harness_runners {
|
||||
if runner.project_markers.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if runner
|
||||
.project_markers
|
||||
.iter()
|
||||
.any(|marker| working_dir.join(marker).exists())
|
||||
{
|
||||
let label = Self::runner_key(name);
|
||||
if !label.is_empty() && !labels.contains(&label) {
|
||||
labels.push(label);
|
||||
}
|
||||
}
|
||||
}
|
||||
labels
|
||||
}
|
||||
|
||||
pub fn runner_key(agent_type: &str) -> String {
|
||||
let canonical = HarnessKind::canonical_agent_type(agent_type);
|
||||
match HarnessKind::from_agent_type(&canonical) {
|
||||
HarnessKind::Unknown if canonical.is_empty() => {
|
||||
HarnessKind::Unknown.as_str().to_string()
|
||||
}
|
||||
HarnessKind::Unknown => canonical,
|
||||
harness => harness.as_str().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn primary_label_for(agent_type: &str, primary: HarnessKind) -> String {
|
||||
match primary {
|
||||
HarnessKind::Unknown => {
|
||||
let label = Self::runner_key(agent_type);
|
||||
if label.is_empty() {
|
||||
HarnessKind::Unknown.as_str().to_string()
|
||||
} else {
|
||||
label
|
||||
}
|
||||
}
|
||||
harness => harness.as_str().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn detect(agent_type: &str, working_dir: &Path) -> Self {
|
||||
let runner_key = Self::runner_key(agent_type);
|
||||
let detected = [
|
||||
HarnessKind::Claude,
|
||||
HarnessKind::Codex,
|
||||
HarnessKind::OpenCode,
|
||||
HarnessKind::Gemini,
|
||||
HarnessKind::Cursor,
|
||||
HarnessKind::Kiro,
|
||||
HarnessKind::Trae,
|
||||
HarnessKind::Zed,
|
||||
HarnessKind::FactoryDroid,
|
||||
HarnessKind::Windsurf,
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|harness| {
|
||||
harness
|
||||
.project_markers()
|
||||
.iter()
|
||||
.any(|marker| working_dir.join(marker).exists())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let primary = match HarnessKind::from_agent_type(&runner_key) {
|
||||
HarnessKind::Unknown if runner_key == HarnessKind::Unknown.as_str() => {
|
||||
detected.first().copied().unwrap_or(HarnessKind::Unknown)
|
||||
}
|
||||
HarnessKind::Unknown => HarnessKind::Unknown,
|
||||
harness => harness,
|
||||
};
|
||||
|
||||
let detected_labels = Self::detected_labels_for(&detected);
|
||||
Self {
|
||||
primary,
|
||||
primary_label: Self::primary_label_for(agent_type, primary),
|
||||
detected,
|
||||
detected_labels,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_persisted(
|
||||
harness_label: &str,
|
||||
agent_type: &str,
|
||||
working_dir: &Path,
|
||||
detected: Vec<HarnessKind>,
|
||||
) -> Self {
|
||||
let primary = HarnessKind::from_db_value(harness_label);
|
||||
if primary == HarnessKind::Unknown && detected.is_empty() && harness_label.trim().is_empty()
|
||||
{
|
||||
return Self::detect(agent_type, working_dir);
|
||||
}
|
||||
|
||||
let normalized_label = harness_label.trim().to_ascii_lowercase();
|
||||
let detected_labels = Self::detected_labels_for(&detected);
|
||||
Self {
|
||||
primary,
|
||||
primary_label: if normalized_label.is_empty() {
|
||||
Self::primary_label_for(agent_type, primary)
|
||||
} else {
|
||||
normalized_label
|
||||
},
|
||||
detected,
|
||||
detected_labels,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_config_detection(
|
||||
mut self,
|
||||
cfg: &crate::config::Config,
|
||||
working_dir: &Path,
|
||||
) -> Self {
|
||||
for label in Self::configured_detected_labels(cfg, working_dir) {
|
||||
if !self.detected_labels.contains(&label) {
|
||||
self.detected_labels.push(label);
|
||||
}
|
||||
}
|
||||
|
||||
if self.primary == HarnessKind::Unknown
|
||||
&& self.primary_label == HarnessKind::Unknown.as_str()
|
||||
&& !self.detected_labels.is_empty()
|
||||
{
|
||||
self.primary_label = self.detected_labels[0].clone();
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub fn resolve_requested_agent_type(
|
||||
cfg: &crate::config::Config,
|
||||
requested_agent_type: &str,
|
||||
working_dir: &Path,
|
||||
) -> String {
|
||||
let canonical = HarnessKind::canonical_agent_type(requested_agent_type);
|
||||
if !canonical.is_empty() && canonical != "auto" {
|
||||
return canonical;
|
||||
}
|
||||
|
||||
let detected = Self::detect("", working_dir).with_config_detection(cfg, working_dir);
|
||||
if detected.primary_label != HarnessKind::Unknown.as_str()
|
||||
&& Self::can_launch_detected_label(cfg, &detected.primary_label)
|
||||
{
|
||||
return Self::runner_key(&detected.primary_label);
|
||||
}
|
||||
|
||||
for label in &detected.detected_labels {
|
||||
if Self::can_launch_detected_label(cfg, label) {
|
||||
return Self::runner_key(label);
|
||||
}
|
||||
}
|
||||
|
||||
HarnessKind::Claude.as_str().to_string()
|
||||
}
|
||||
|
||||
fn can_launch_detected_label(cfg: &crate::config::Config, label: &str) -> bool {
|
||||
cfg.harness_runner(label).is_some()
|
||||
|| HarnessKind::from_agent_type(label).supports_direct_execution()
|
||||
}
|
||||
|
||||
pub fn detected_summary(&self) -> String {
|
||||
if self.detected_labels.is_empty() {
|
||||
"none detected".to_string()
|
||||
} else {
|
||||
self.detected_labels.join(", ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
pub id: String,
|
||||
pub task: String,
|
||||
pub project: String,
|
||||
pub task_group: String,
|
||||
pub agent_type: String,
|
||||
pub working_dir: PathBuf,
|
||||
pub state: SessionState,
|
||||
@@ -314,7 +20,6 @@ pub struct Session {
|
||||
pub worktree: Option<WorktreeInfo>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub last_heartbeat_at: DateTime<Utc>,
|
||||
pub metrics: SessionMetrics,
|
||||
}
|
||||
|
||||
@@ -323,7 +28,6 @@ pub enum SessionState {
|
||||
Pending,
|
||||
Running,
|
||||
Idle,
|
||||
Stale,
|
||||
Completed,
|
||||
Failed,
|
||||
Stopped,
|
||||
@@ -335,7 +39,6 @@ impl fmt::Display for SessionState {
|
||||
SessionState::Pending => write!(f, "pending"),
|
||||
SessionState::Running => write!(f, "running"),
|
||||
SessionState::Idle => write!(f, "idle"),
|
||||
SessionState::Stale => write!(f, "stale"),
|
||||
SessionState::Completed => write!(f, "completed"),
|
||||
SessionState::Failed => write!(f, "failed"),
|
||||
SessionState::Stopped => write!(f, "stopped"),
|
||||
@@ -357,21 +60,12 @@ impl SessionState {
|
||||
) | (
|
||||
SessionState::Running,
|
||||
SessionState::Idle
|
||||
| SessionState::Stale
|
||||
| SessionState::Completed
|
||||
| SessionState::Failed
|
||||
| SessionState::Stopped
|
||||
) | (
|
||||
SessionState::Idle,
|
||||
SessionState::Running
|
||||
| SessionState::Stale
|
||||
| SessionState::Completed
|
||||
| SessionState::Failed
|
||||
| SessionState::Stopped
|
||||
) | (
|
||||
SessionState::Stale,
|
||||
SessionState::Running
|
||||
| SessionState::Idle
|
||||
| SessionState::Completed
|
||||
| SessionState::Failed
|
||||
| SessionState::Stopped
|
||||
@@ -384,7 +78,6 @@ impl SessionState {
|
||||
match value {
|
||||
"running" => SessionState::Running,
|
||||
"idle" => SessionState::Idle,
|
||||
"stale" => SessionState::Stale,
|
||||
"completed" => SessionState::Completed,
|
||||
"failed" => SessionState::Failed,
|
||||
"stopped" => SessionState::Stopped,
|
||||
@@ -402,8 +95,6 @@ pub struct WorktreeInfo {
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct SessionMetrics {
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub tokens_used: u64,
|
||||
pub tool_calls: u64,
|
||||
pub files_changed: u32,
|
||||
@@ -411,6 +102,27 @@ pub struct SessionMetrics {
|
||||
pub cost_usd: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SessionBoardMeta {
|
||||
pub lane: String,
|
||||
pub project: Option<String>,
|
||||
pub feature: Option<String>,
|
||||
pub issue: Option<String>,
|
||||
pub row_label: Option<String>,
|
||||
pub previous_lane: Option<String>,
|
||||
pub previous_row_label: Option<String>,
|
||||
pub column_index: i64,
|
||||
pub row_index: i64,
|
||||
pub stack_index: i64,
|
||||
pub progress_percent: i64,
|
||||
pub status_detail: Option<String>,
|
||||
pub movement_note: Option<String>,
|
||||
pub activity_kind: Option<String>,
|
||||
pub activity_note: Option<String>,
|
||||
pub handoff_backlog: i64,
|
||||
pub conflict_signal: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionMessage {
|
||||
pub id: i64,
|
||||
@@ -421,543 +133,3 @@ pub struct SessionMessage {
|
||||
pub read: bool,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ScheduledTask {
|
||||
pub id: i64,
|
||||
pub cron_expr: String,
|
||||
pub task: String,
|
||||
pub agent_type: String,
|
||||
pub profile_name: Option<String>,
|
||||
pub working_dir: PathBuf,
|
||||
pub project: String,
|
||||
pub task_group: String,
|
||||
pub use_worktree: bool,
|
||||
pub last_run_at: Option<DateTime<Utc>>,
|
||||
pub next_run_at: DateTime<Utc>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RemoteDispatchRequest {
|
||||
pub id: i64,
|
||||
pub request_kind: RemoteDispatchKind,
|
||||
pub target_session_id: Option<String>,
|
||||
pub task: String,
|
||||
pub target_url: Option<String>,
|
||||
pub priority: crate::comms::TaskPriority,
|
||||
pub agent_type: String,
|
||||
pub profile_name: Option<String>,
|
||||
pub working_dir: PathBuf,
|
||||
pub project: String,
|
||||
pub task_group: String,
|
||||
pub use_worktree: bool,
|
||||
pub source: String,
|
||||
pub requester: Option<String>,
|
||||
pub status: RemoteDispatchStatus,
|
||||
pub result_session_id: Option<String>,
|
||||
pub result_action: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub dispatched_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RemoteDispatchKind {
|
||||
Standard,
|
||||
ComputerUse,
|
||||
}
|
||||
|
||||
impl fmt::Display for RemoteDispatchKind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Standard => write!(f, "standard"),
|
||||
Self::ComputerUse => write!(f, "computer_use"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteDispatchKind {
|
||||
pub fn from_db_value(value: &str) -> Self {
|
||||
match value {
|
||||
"computer_use" => Self::ComputerUse,
|
||||
_ => Self::Standard,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RemoteDispatchStatus {
|
||||
Pending,
|
||||
Dispatched,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl fmt::Display for RemoteDispatchStatus {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Pending => write!(f, "pending"),
|
||||
Self::Dispatched => write!(f, "dispatched"),
|
||||
Self::Failed => write!(f, "failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteDispatchStatus {
|
||||
pub fn from_db_value(value: &str) -> Self {
|
||||
match value {
|
||||
"dispatched" => Self::Dispatched,
|
||||
"failed" => Self::Failed,
|
||||
_ => Self::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct FileActivityEntry {
|
||||
pub session_id: String,
|
||||
pub action: FileActivityAction,
|
||||
pub path: String,
|
||||
pub summary: String,
|
||||
pub diff_preview: Option<String>,
|
||||
pub patch_preview: Option<String>,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct DecisionLogEntry {
|
||||
pub id: i64,
|
||||
pub session_id: String,
|
||||
pub decision: String,
|
||||
pub alternatives: Vec<String>,
|
||||
pub reasoning: String,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ContextGraphEntity {
|
||||
pub id: i64,
|
||||
pub session_id: Option<String>,
|
||||
pub entity_type: String,
|
||||
pub name: String,
|
||||
pub path: Option<String>,
|
||||
pub summary: String,
|
||||
pub metadata: BTreeMap<String, String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ContextGraphRelation {
|
||||
pub id: i64,
|
||||
pub session_id: Option<String>,
|
||||
pub from_entity_id: i64,
|
||||
pub from_entity_type: String,
|
||||
pub from_entity_name: String,
|
||||
pub to_entity_id: i64,
|
||||
pub to_entity_type: String,
|
||||
pub to_entity_name: String,
|
||||
pub relation_type: String,
|
||||
pub summary: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ContextGraphEntityDetail {
|
||||
pub entity: ContextGraphEntity,
|
||||
pub outgoing: Vec<ContextGraphRelation>,
|
||||
pub incoming: Vec<ContextGraphRelation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ContextGraphObservation {
|
||||
pub id: i64,
|
||||
pub session_id: Option<String>,
|
||||
pub entity_id: i64,
|
||||
pub entity_type: String,
|
||||
pub entity_name: String,
|
||||
pub observation_type: String,
|
||||
pub priority: ContextObservationPriority,
|
||||
pub pinned: bool,
|
||||
pub summary: String,
|
||||
pub details: BTreeMap<String, String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ContextGraphRecallEntry {
|
||||
pub entity: ContextGraphEntity,
|
||||
pub score: u64,
|
||||
pub matched_terms: Vec<String>,
|
||||
pub relation_count: usize,
|
||||
pub observation_count: usize,
|
||||
pub max_observation_priority: ContextObservationPriority,
|
||||
pub has_pinned_observation: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContextObservationPriority {
|
||||
Low,
|
||||
Normal,
|
||||
High,
|
||||
Critical,
|
||||
}
|
||||
|
||||
impl Default for ContextObservationPriority {
|
||||
fn default() -> Self {
|
||||
Self::Normal
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ContextObservationPriority {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Low => write!(f, "low"),
|
||||
Self::Normal => write!(f, "normal"),
|
||||
Self::High => write!(f, "high"),
|
||||
Self::Critical => write!(f, "critical"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ContextObservationPriority {
|
||||
pub fn from_db_value(value: i64) -> Self {
|
||||
match value {
|
||||
0 => Self::Low,
|
||||
2 => Self::High,
|
||||
3 => Self::Critical,
|
||||
_ => Self::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_db_value(self) -> i64 {
|
||||
match self {
|
||||
Self::Low => 0,
|
||||
Self::Normal => 1,
|
||||
Self::High => 2,
|
||||
Self::Critical => 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ContextGraphSyncStats {
|
||||
pub sessions_scanned: usize,
|
||||
pub decisions_processed: usize,
|
||||
pub file_events_processed: usize,
|
||||
pub messages_processed: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ContextGraphCompactionStats {
|
||||
pub entities_scanned: usize,
|
||||
pub duplicate_observations_deleted: usize,
|
||||
pub overflow_observations_deleted: usize,
|
||||
pub observations_retained: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FileActivityAction {
|
||||
Read,
|
||||
Create,
|
||||
Modify,
|
||||
Move,
|
||||
Delete,
|
||||
Touch,
|
||||
}
|
||||
|
||||
pub fn normalize_group_label(value: &str) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_project_label(working_dir: &Path) -> String {
|
||||
working_dir
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.and_then(normalize_group_label)
|
||||
.unwrap_or_else(|| "workspace".to_string())
|
||||
}
|
||||
|
||||
pub fn default_task_group_label(task: &str) -> String {
|
||||
normalize_group_label(task).unwrap_or_else(|| "general".to_string())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SessionGrouping {
|
||||
pub project: Option<String>,
|
||||
pub task_group: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
struct TestDir {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl TestDir {
|
||||
fn new(label: &str) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("ecc2-{}-{}", label, uuid::Uuid::new_v4()));
|
||||
fs::create_dir_all(&path)?;
|
||||
Ok(Self { path })
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_session_harness_prefers_agent_type_and_collects_project_markers(
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let repo = TestDir::new("session-harness-detect")?;
|
||||
fs::create_dir_all(repo.path().join(".codex"))?;
|
||||
fs::create_dir_all(repo.path().join(".claude"))?;
|
||||
|
||||
let harness = SessionHarnessInfo::detect("claude", repo.path());
|
||||
assert_eq!(harness.primary, HarnessKind::Claude);
|
||||
assert_eq!(harness.primary_label, "claude");
|
||||
assert_eq!(
|
||||
harness.detected,
|
||||
vec![HarnessKind::Claude, HarnessKind::Codex]
|
||||
);
|
||||
assert_eq!(harness.detected_labels, vec!["claude", "codex"]);
|
||||
assert_eq!(harness.detected_summary(), "claude, codex");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_session_harness_falls_back_to_project_markers_when_agent_unspecified(
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let repo = TestDir::new("session-harness-markers")?;
|
||||
fs::create_dir_all(repo.path().join(".gemini"))?;
|
||||
|
||||
let harness = SessionHarnessInfo::detect("", repo.path());
|
||||
assert_eq!(harness.primary, HarnessKind::Gemini);
|
||||
assert_eq!(harness.primary_label, "gemini");
|
||||
assert_eq!(harness.detected, vec![HarnessKind::Gemini]);
|
||||
assert_eq!(harness.detected_labels, vec!["gemini"]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_session_harness_collects_extended_builtin_markers(
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let repo = TestDir::new("session-harness-extended-markers")?;
|
||||
fs::create_dir_all(repo.path().join(".zed"))?;
|
||||
fs::create_dir_all(repo.path().join(".factory-droid"))?;
|
||||
fs::create_dir_all(repo.path().join(".windsurf"))?;
|
||||
|
||||
let harness = SessionHarnessInfo::detect("", repo.path());
|
||||
assert_eq!(harness.primary, HarnessKind::Zed);
|
||||
assert_eq!(harness.primary_label, "zed");
|
||||
assert_eq!(
|
||||
harness.detected,
|
||||
vec![
|
||||
HarnessKind::Zed,
|
||||
HarnessKind::FactoryDroid,
|
||||
HarnessKind::Windsurf
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
harness.detected_labels,
|
||||
vec!["zed", "factory_droid", "windsurf"]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_agent_type_normalizes_known_aliases() {
|
||||
assert_eq!(HarnessKind::canonical_agent_type("claude-code"), "claude");
|
||||
assert_eq!(HarnessKind::canonical_agent_type("gemini-cli"), "gemini");
|
||||
assert_eq!(
|
||||
HarnessKind::canonical_agent_type("factory-droid"),
|
||||
"factory_droid"
|
||||
);
|
||||
assert_eq!(
|
||||
HarnessKind::canonical_agent_type(" custom-runner "),
|
||||
"custom-runner"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_session_harness_preserves_custom_agent_label_without_markers() {
|
||||
let harness = SessionHarnessInfo::detect(" custom-runner ", Path::new("."));
|
||||
assert_eq!(harness.primary, HarnessKind::Unknown);
|
||||
assert_eq!(harness.primary_label, "custom-runner");
|
||||
assert!(harness.detected.is_empty());
|
||||
assert!(harness.detected_labels.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_session_harness_preserves_custom_agent_label_with_project_markers(
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let repo = TestDir::new("session-harness-custom-markers")?;
|
||||
fs::create_dir_all(repo.path().join(".claude"))?;
|
||||
fs::create_dir_all(repo.path().join(".codex"))?;
|
||||
|
||||
let harness = SessionHarnessInfo::detect("custom-runner", repo.path());
|
||||
assert_eq!(harness.primary, HarnessKind::Unknown);
|
||||
assert_eq!(harness.primary_label, "custom-runner");
|
||||
assert_eq!(
|
||||
harness.detected,
|
||||
vec![HarnessKind::Claude, HarnessKind::Codex]
|
||||
);
|
||||
assert_eq!(harness.detected_labels, vec!["claude", "codex"]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_detection_adds_custom_markers_to_detected_summary(
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let repo = TestDir::new("session-harness-custom-config")?;
|
||||
fs::create_dir_all(repo.path().join(".acme"))?;
|
||||
let mut cfg = crate::config::Config::default();
|
||||
cfg.harness_runners.insert(
|
||||
"acme-runner".to_string(),
|
||||
crate::config::HarnessRunnerConfig {
|
||||
project_markers: vec![PathBuf::from(".acme")],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let harness =
|
||||
SessionHarnessInfo::detect("", repo.path()).with_config_detection(&cfg, repo.path());
|
||||
assert_eq!(harness.primary, HarnessKind::Unknown);
|
||||
assert_eq!(harness.primary_label, "acme-runner");
|
||||
assert_eq!(harness.detected_labels, vec!["acme-runner"]);
|
||||
assert_eq!(harness.detected_summary(), "acme-runner");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_detection_preserves_custom_primary_label_and_appends_marker_matches(
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let repo = TestDir::new("session-harness-config-append")?;
|
||||
fs::create_dir_all(repo.path().join(".acme"))?;
|
||||
fs::create_dir_all(repo.path().join(".codex"))?;
|
||||
let mut cfg = crate::config::Config::default();
|
||||
cfg.harness_runners.insert(
|
||||
"acme-runner".to_string(),
|
||||
crate::config::HarnessRunnerConfig {
|
||||
project_markers: vec![PathBuf::from(".acme")],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let harness = SessionHarnessInfo::detect("acme-runner", repo.path())
|
||||
.with_config_detection(&cfg, repo.path());
|
||||
assert_eq!(harness.primary, HarnessKind::Unknown);
|
||||
assert_eq!(harness.primary_label, "acme-runner");
|
||||
assert_eq!(harness.detected_labels, vec!["codex", "acme-runner"]);
|
||||
assert_eq!(harness.detected_summary(), "codex, acme-runner");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runner_key_uses_canonical_label_for_unknown_harnesses() {
|
||||
assert_eq!(
|
||||
SessionHarnessInfo::runner_key(" custom-runner "),
|
||||
"custom-runner"
|
||||
);
|
||||
assert_eq!(SessionHarnessInfo::runner_key("claude-code"), "claude");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_requested_agent_type_uses_detected_builtin_marker_for_auto(
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let repo = TestDir::new("session-harness-resolve-auto-built-in")?;
|
||||
fs::create_dir_all(repo.path().join(".codex"))?;
|
||||
|
||||
let resolved = SessionHarnessInfo::resolve_requested_agent_type(
|
||||
&crate::config::Config::default(),
|
||||
"auto",
|
||||
repo.path(),
|
||||
);
|
||||
assert_eq!(resolved, "codex");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_requested_agent_type_uses_configured_marker_for_auto(
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let repo = TestDir::new("session-harness-resolve-auto-custom")?;
|
||||
fs::create_dir_all(repo.path().join(".acme"))?;
|
||||
let mut cfg = crate::config::Config::default();
|
||||
cfg.harness_runners.insert(
|
||||
"acme-runner".to_string(),
|
||||
crate::config::HarnessRunnerConfig {
|
||||
project_markers: vec![PathBuf::from(".acme")],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let resolved = SessionHarnessInfo::resolve_requested_agent_type(&cfg, "auto", repo.path());
|
||||
assert_eq!(resolved, "acme-runner");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_requested_agent_type_skips_nonlaunchable_builtin_markers_without_runner(
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let repo = TestDir::new("session-harness-resolve-auto-nonlaunchable")?;
|
||||
fs::create_dir_all(repo.path().join(".zed"))?;
|
||||
|
||||
let resolved = SessionHarnessInfo::resolve_requested_agent_type(
|
||||
&crate::config::Config::default(),
|
||||
"auto",
|
||||
repo.path(),
|
||||
);
|
||||
assert_eq!(resolved, "claude");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_requested_agent_type_uses_configured_runner_for_extended_builtin_markers(
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let repo = TestDir::new("session-harness-resolve-auto-extended-runner")?;
|
||||
fs::create_dir_all(repo.path().join(".windsurf"))?;
|
||||
let mut cfg = crate::config::Config::default();
|
||||
cfg.harness_runners.insert(
|
||||
"windsurf".to_string(),
|
||||
crate::config::HarnessRunnerConfig {
|
||||
program: "windsurf".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let resolved = SessionHarnessInfo::resolve_requested_agent_type(&cfg, "auto", repo.path());
|
||||
assert_eq!(resolved, "windsurf");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_requested_agent_type_falls_back_to_claude_without_markers() {
|
||||
let resolved = SessionHarnessInfo::resolve_requested_agent_type(
|
||||
&crate::config::Config::default(),
|
||||
"auto",
|
||||
Path::new("."),
|
||||
);
|
||||
assert_eq!(resolved, "claude");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,31 +32,6 @@ impl OutputStream {
|
||||
pub struct OutputLine {
|
||||
pub stream: OutputStream,
|
||||
pub text: String,
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
impl OutputLine {
|
||||
pub fn new(
|
||||
stream: OutputStream,
|
||||
text: impl Into<String>,
|
||||
timestamp: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
stream,
|
||||
text: text.into(),
|
||||
timestamp: timestamp.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_current_timestamp(stream: OutputStream, text: impl Into<String>) -> Self {
|
||||
Self::new(stream, text, chrono::Utc::now().to_rfc3339())
|
||||
}
|
||||
|
||||
pub fn occurred_at(&self) -> Option<chrono::DateTime<chrono::Utc>> {
|
||||
chrono::DateTime::parse_from_rfc3339(&self.timestamp)
|
||||
.ok()
|
||||
.map(|timestamp| timestamp.with_timezone(&chrono::Utc))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -95,7 +70,10 @@ impl SessionOutputStore {
|
||||
}
|
||||
|
||||
pub fn push_line(&self, session_id: &str, stream: OutputStream, text: impl Into<String>) {
|
||||
let line = OutputLine::with_current_timestamp(stream, text);
|
||||
let line = OutputLine {
|
||||
stream,
|
||||
text: text.into(),
|
||||
};
|
||||
|
||||
{
|
||||
let mut buffers = self.lock_buffers();
|
||||
@@ -167,6 +145,5 @@ mod tests {
|
||||
assert_eq!(event.session_id, "session-1");
|
||||
assert_eq!(event.line.stream, OutputStream::Stderr);
|
||||
assert_eq!(event.line.text, "problem");
|
||||
assert!(event.line.occurred_at().is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ use anyhow::{Context, Result};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader};
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tokio::time::{self, MissedTickBehavior};
|
||||
|
||||
use super::output::{OutputStream, SessionOutputStore};
|
||||
use super::store::StateStore;
|
||||
@@ -27,9 +26,6 @@ enum DbMessage {
|
||||
line: String,
|
||||
ack: oneshot::Sender<DbAck>,
|
||||
},
|
||||
TouchHeartbeat {
|
||||
ack: oneshot::Sender<DbAck>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -57,10 +53,6 @@ impl DbWriter {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn touch_heartbeat(&self) -> Result<()> {
|
||||
self.send(|ack| DbMessage::TouchHeartbeat { ack }).await
|
||||
}
|
||||
|
||||
async fn send<F>(&self, build: F) -> Result<()>
|
||||
where
|
||||
F: FnOnce(oneshot::Sender<DbAck>) -> DbMessage,
|
||||
@@ -119,17 +111,6 @@ fn run_db_writer(db_path: PathBuf, session_id: String, mut rx: mpsc::UnboundedRe
|
||||
};
|
||||
let _ = ack.send(result);
|
||||
}
|
||||
DbMessage::TouchHeartbeat { ack } => {
|
||||
let result = match opened.as_ref() {
|
||||
Some(db) => db
|
||||
.touch_heartbeat(&session_id)
|
||||
.map_err(|error| error.to_string()),
|
||||
None => Err(open_error
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Failed to open state store".to_string())),
|
||||
};
|
||||
let _ = ack.send(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,7 +120,6 @@ pub async fn capture_command_output(
|
||||
session_id: String,
|
||||
mut command: Command,
|
||||
output_store: SessionOutputStore,
|
||||
heartbeat_interval: std::time::Duration,
|
||||
) -> Result<ExitStatus> {
|
||||
let db_writer = DbWriter::start(db_path, session_id.clone());
|
||||
|
||||
@@ -172,19 +152,6 @@ pub async fn capture_command_output(
|
||||
.ok_or_else(|| anyhow::anyhow!("Spawned process did not expose a process id"))?;
|
||||
db_writer.update_pid(Some(pid)).await?;
|
||||
db_writer.update_state(SessionState::Running).await?;
|
||||
db_writer.touch_heartbeat().await?;
|
||||
|
||||
let heartbeat_writer = db_writer.clone();
|
||||
let heartbeat_task = tokio::spawn(async move {
|
||||
let mut ticker = time::interval(heartbeat_interval);
|
||||
ticker.set_missed_tick_behavior(MissedTickBehavior::Delay);
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
if heartbeat_writer.touch_heartbeat().await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let stdout_task = tokio::spawn(capture_stream(
|
||||
session_id.clone(),
|
||||
@@ -202,8 +169,6 @@ pub async fn capture_command_output(
|
||||
));
|
||||
|
||||
let status = child.wait().await?;
|
||||
heartbeat_task.abort();
|
||||
let _ = heartbeat_task.await;
|
||||
stdout_task.await??;
|
||||
stderr_task.await??;
|
||||
|
||||
@@ -272,8 +237,6 @@ mod tests {
|
||||
db.insert_session(&Session {
|
||||
id: session_id.clone(),
|
||||
task: "stream output".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "test".to_string(),
|
||||
working_dir: env::temp_dir(),
|
||||
state: SessionState::Pending,
|
||||
@@ -281,7 +244,6 @@ mod tests {
|
||||
worktree: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
last_heartbeat_at: now,
|
||||
metrics: SessionMetrics::default(),
|
||||
})?;
|
||||
|
||||
@@ -292,14 +254,9 @@ mod tests {
|
||||
.arg("-c")
|
||||
.arg("printf 'alpha\\n'; printf 'beta\\n' >&2");
|
||||
|
||||
let status = capture_command_output(
|
||||
db_path.clone(),
|
||||
session_id.clone(),
|
||||
command,
|
||||
output_store,
|
||||
std::time::Duration::from_millis(10),
|
||||
)
|
||||
.await?;
|
||||
let status =
|
||||
capture_command_output(db_path.clone(), session_id.clone(), command, output_store)
|
||||
.await?;
|
||||
|
||||
assert!(status.success());
|
||||
|
||||
@@ -329,51 +286,4 @@ mod tests {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capture_command_output_updates_heartbeat_for_quiet_processes() -> Result<()> {
|
||||
let db_path = env::temp_dir().join(format!("ecc2-runtime-heartbeat-{}.db", Uuid::new_v4()));
|
||||
let db = StateStore::open(&db_path)?;
|
||||
let session_id = "session-heartbeat".to_string();
|
||||
let now = Utc::now();
|
||||
|
||||
db.insert_session(&Session {
|
||||
id: session_id.clone(),
|
||||
task: "quiet process".to_string(),
|
||||
project: "workspace".to_string(),
|
||||
task_group: "general".to_string(),
|
||||
agent_type: "test".to_string(),
|
||||
working_dir: env::temp_dir(),
|
||||
state: SessionState::Pending,
|
||||
pid: None,
|
||||
worktree: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
last_heartbeat_at: now,
|
||||
metrics: SessionMetrics::default(),
|
||||
})?;
|
||||
|
||||
let mut command = Command::new("/bin/sh");
|
||||
command.arg("-c").arg("sleep 0.05");
|
||||
|
||||
let _ = capture_command_output(
|
||||
db_path.clone(),
|
||||
session_id.clone(),
|
||||
command,
|
||||
SessionOutputStore::default(),
|
||||
std::time::Duration::from_millis(10),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let db = StateStore::open(&db_path)?;
|
||||
let session = db
|
||||
.get_session(&session_id)?
|
||||
.expect("session should still exist");
|
||||
|
||||
assert!(session.last_heartbeat_at > now);
|
||||
assert_eq!(session.state, SessionState::Completed);
|
||||
|
||||
let _ = std::fs::remove_file(db_path);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
+885
-4831
File diff suppressed because it is too large
Load Diff
@@ -27,49 +27,9 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> {
|
||||
|
||||
if event::poll(Duration::from_millis(250))? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
if dashboard.has_active_completion_popup() {
|
||||
match (key.modifiers, key.code) {
|
||||
(KeyModifiers::CONTROL, KeyCode::Char('c')) => break,
|
||||
(_, KeyCode::Esc) | (_, KeyCode::Enter) | (_, KeyCode::Char(' ')) => {
|
||||
dashboard.dismiss_completion_popup();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if dashboard.is_input_mode() {
|
||||
match (key.modifiers, key.code) {
|
||||
(KeyModifiers::CONTROL, KeyCode::Char('c')) => break,
|
||||
(_, KeyCode::Esc) => dashboard.cancel_input(),
|
||||
(_, KeyCode::Enter) => dashboard.submit_input().await,
|
||||
(_, KeyCode::Backspace) => dashboard.pop_input_char(),
|
||||
(modifiers, KeyCode::Char(ch))
|
||||
if !modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& !modifiers.contains(KeyModifiers::ALT) =>
|
||||
{
|
||||
dashboard.push_input_char(ch);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if dashboard.is_pane_command_mode() {
|
||||
if dashboard.handle_pane_command_key(key) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
match (key.modifiers, key.code) {
|
||||
(KeyModifiers::CONTROL, KeyCode::Char('c')) => break,
|
||||
(KeyModifiers::CONTROL, KeyCode::Char('w')) => {
|
||||
dashboard.begin_pane_command_mode()
|
||||
}
|
||||
(_, KeyCode::Char('q')) => break,
|
||||
_ if dashboard.handle_pane_navigation_key(key) => {}
|
||||
(_, KeyCode::Tab) => dashboard.next_pane(),
|
||||
(KeyModifiers::SHIFT, KeyCode::BackTab) => dashboard.prev_pane(),
|
||||
(_, KeyCode::Char('+')) | (_, KeyCode::Char('=')) => {
|
||||
@@ -78,53 +38,17 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> {
|
||||
(_, KeyCode::Char('-')) => dashboard.decrease_pane_size(),
|
||||
(_, KeyCode::Char('j')) | (_, KeyCode::Down) => dashboard.scroll_down(),
|
||||
(_, KeyCode::Char('k')) | (_, KeyCode::Up) => dashboard.scroll_up(),
|
||||
(_, KeyCode::Char('[')) => dashboard.focus_previous_delegate(),
|
||||
(_, KeyCode::Char(']')) => dashboard.focus_next_delegate(),
|
||||
(_, KeyCode::Enter) => dashboard.open_focused_delegate(),
|
||||
(_, KeyCode::Char('/')) => dashboard.begin_search(),
|
||||
(_, KeyCode::Esc) => dashboard.clear_search(),
|
||||
(_, KeyCode::Char('n')) if dashboard.has_active_search() => {
|
||||
dashboard.next_search_match()
|
||||
}
|
||||
(_, KeyCode::Char('N')) if dashboard.has_active_search() => {
|
||||
dashboard.prev_search_match()
|
||||
}
|
||||
(_, KeyCode::Char('N')) => dashboard.begin_spawn_prompt(),
|
||||
(_, KeyCode::Char('n')) => dashboard.new_session().await,
|
||||
(_, KeyCode::Char('a')) => dashboard.assign_selected().await,
|
||||
(_, KeyCode::Char('b')) => dashboard.rebalance_selected_team().await,
|
||||
(_, KeyCode::Char('B')) => dashboard.rebalance_all_teams().await,
|
||||
(_, KeyCode::Char('i')) => dashboard.drain_inbox_selected().await,
|
||||
(_, KeyCode::Char('I')) => dashboard.focus_next_approval_target(),
|
||||
(_, KeyCode::Char('g')) => dashboard.auto_dispatch_backlog().await,
|
||||
(_, KeyCode::Char('G')) => dashboard.coordinate_backlog().await,
|
||||
(_, KeyCode::Char('K')) => dashboard.toggle_context_graph_mode(),
|
||||
(_, KeyCode::Char('h')) => dashboard.collapse_selected_pane(),
|
||||
(_, KeyCode::Char('H')) => dashboard.restore_collapsed_panes(),
|
||||
(_, KeyCode::Char('y')) => dashboard.toggle_timeline_mode(),
|
||||
(_, KeyCode::Char('E')) if dashboard.is_context_graph_mode() => {
|
||||
dashboard.cycle_graph_entity_filter()
|
||||
}
|
||||
(_, KeyCode::Char('E')) => dashboard.cycle_timeline_event_filter(),
|
||||
(_, KeyCode::Char('v')) => dashboard.toggle_output_mode(),
|
||||
(_, KeyCode::Char('z')) => dashboard.toggle_git_status_mode(),
|
||||
(_, KeyCode::Char('V')) => dashboard.toggle_diff_view_mode(),
|
||||
(_, KeyCode::Char('S')) => dashboard.stage_selected_git_status(),
|
||||
(_, KeyCode::Char('U')) => dashboard.unstage_selected_git_status(),
|
||||
(_, KeyCode::Char('R')) => dashboard.reset_selected_git_status(),
|
||||
(_, KeyCode::Char('C')) => dashboard.begin_commit_prompt(),
|
||||
(_, KeyCode::Char('P')) => dashboard.begin_pr_prompt(),
|
||||
(_, KeyCode::Char('{')) => dashboard.prev_diff_hunk(),
|
||||
(_, KeyCode::Char('}')) => dashboard.next_diff_hunk(),
|
||||
(_, KeyCode::Char('c')) => dashboard.toggle_conflict_protocol_mode(),
|
||||
(_, KeyCode::Char('e')) => dashboard.toggle_output_filter(),
|
||||
(_, KeyCode::Char('f')) => dashboard.cycle_output_time_filter(),
|
||||
(_, KeyCode::Char('A')) => dashboard.toggle_search_scope(),
|
||||
(_, KeyCode::Char('o')) => dashboard.toggle_search_agent_filter(),
|
||||
(_, KeyCode::Char('m')) => dashboard.merge_selected_worktree().await,
|
||||
(_, KeyCode::Char('M')) => dashboard.merge_ready_worktrees().await,
|
||||
(_, KeyCode::Char('l')) => dashboard.cycle_pane_layout(),
|
||||
(_, KeyCode::Char('T')) => dashboard.toggle_theme(),
|
||||
(_, KeyCode::Char('p')) => dashboard.toggle_auto_dispatch_policy(),
|
||||
(_, KeyCode::Char('t')) => dashboard.toggle_auto_worktree_policy(),
|
||||
(_, KeyCode::Char('w')) => dashboard.toggle_auto_merge_policy(),
|
||||
|
||||
+888
-10658
File diff suppressed because it is too large
Load Diff
+33
-134
@@ -1,49 +1,30 @@
|
||||
use crate::config::BudgetAlertThresholds;
|
||||
|
||||
use ratatui::{
|
||||
prelude::*,
|
||||
text::{Line, Span},
|
||||
widgets::{Gauge, Paragraph, Widget},
|
||||
};
|
||||
|
||||
pub(crate) const WARNING_THRESHOLD: f64 = 0.8;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub(crate) enum BudgetState {
|
||||
Unconfigured,
|
||||
Normal,
|
||||
Alert50,
|
||||
Alert75,
|
||||
Alert90,
|
||||
Warning,
|
||||
OverBudget,
|
||||
}
|
||||
|
||||
impl BudgetState {
|
||||
fn badge(self, thresholds: BudgetAlertThresholds) -> Option<String> {
|
||||
match self {
|
||||
Self::Alert50 => Some(threshold_label(thresholds.advisory)),
|
||||
Self::Alert75 => Some(threshold_label(thresholds.warning)),
|
||||
Self::Alert90 => Some(threshold_label(thresholds.critical)),
|
||||
Self::OverBudget => Some("over budget".to_string()),
|
||||
Self::Unconfigured => Some("no budget".to_string()),
|
||||
Self::Normal => None,
|
||||
}
|
||||
pub(crate) const fn is_warning(self) -> bool {
|
||||
matches!(self, Self::Warning | Self::OverBudget)
|
||||
}
|
||||
|
||||
pub(crate) fn summary_suffix(self, thresholds: BudgetAlertThresholds) -> Option<String> {
|
||||
fn badge(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::Alert50 => Some(format!(
|
||||
"Budget alert {}",
|
||||
threshold_label(thresholds.advisory)
|
||||
)),
|
||||
Self::Alert75 => Some(format!(
|
||||
"Budget alert {}",
|
||||
threshold_label(thresholds.warning)
|
||||
)),
|
||||
Self::Alert90 => Some(format!(
|
||||
"Budget alert {}",
|
||||
threshold_label(thresholds.critical)
|
||||
)),
|
||||
Self::OverBudget => Some("Budget exceeded".to_string()),
|
||||
Self::Unconfigured | Self::Normal => None,
|
||||
Self::Warning => Some("warning"),
|
||||
Self::OverBudget => Some("over budget"),
|
||||
Self::Unconfigured => Some("no budget"),
|
||||
Self::Normal => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,13 +32,11 @@ impl BudgetState {
|
||||
let base = Style::default().fg(match self {
|
||||
Self::Unconfigured => Color::DarkGray,
|
||||
Self::Normal => Color::DarkGray,
|
||||
Self::Alert50 => Color::Cyan,
|
||||
Self::Alert75 => Color::Yellow,
|
||||
Self::Alert90 => Color::LightRed,
|
||||
Self::Warning => Color::Yellow,
|
||||
Self::OverBudget => Color::Red,
|
||||
});
|
||||
|
||||
if matches!(self, Self::Alert75 | Self::Alert90 | Self::OverBudget) {
|
||||
if self.is_warning() {
|
||||
base.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
base
|
||||
@@ -76,43 +55,30 @@ pub(crate) struct TokenMeter<'a> {
|
||||
title: &'a str,
|
||||
used: f64,
|
||||
budget: f64,
|
||||
thresholds: BudgetAlertThresholds,
|
||||
format: MeterFormat,
|
||||
}
|
||||
|
||||
impl<'a> TokenMeter<'a> {
|
||||
pub(crate) fn tokens(
|
||||
title: &'a str,
|
||||
used: u64,
|
||||
budget: u64,
|
||||
thresholds: BudgetAlertThresholds,
|
||||
) -> Self {
|
||||
pub(crate) fn tokens(title: &'a str, used: u64, budget: u64) -> Self {
|
||||
Self {
|
||||
title,
|
||||
used: used as f64,
|
||||
budget: budget as f64,
|
||||
thresholds,
|
||||
format: MeterFormat::Tokens,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn currency(
|
||||
title: &'a str,
|
||||
used: f64,
|
||||
budget: f64,
|
||||
thresholds: BudgetAlertThresholds,
|
||||
) -> Self {
|
||||
pub(crate) fn currency(title: &'a str, used: f64, budget: f64) -> Self {
|
||||
Self {
|
||||
title,
|
||||
used,
|
||||
budget,
|
||||
thresholds,
|
||||
format: MeterFormat::Currency,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn state(&self) -> BudgetState {
|
||||
budget_state(self.used, self.budget, self.thresholds)
|
||||
budget_state(self.used, self.budget)
|
||||
}
|
||||
|
||||
fn ratio(&self) -> f64 {
|
||||
@@ -131,7 +97,7 @@ impl<'a> TokenMeter<'a> {
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)];
|
||||
|
||||
if let Some(badge) = self.state().badge(self.thresholds) {
|
||||
if let Some(badge) = self.state().badge() {
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled(format!("[{badge}]"), self.state().style()));
|
||||
}
|
||||
@@ -199,7 +165,7 @@ impl Widget for TokenMeter<'_> {
|
||||
.label(self.display_label())
|
||||
.gauge_style(
|
||||
Style::default()
|
||||
.fg(gradient_color(self.ratio(), self.thresholds))
|
||||
.fg(gradient_color(self.ratio()))
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.style(Style::default().fg(Color::DarkGray))
|
||||
@@ -216,51 +182,35 @@ pub(crate) fn budget_ratio(used: f64, budget: f64) -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn budget_state(
|
||||
used: f64,
|
||||
budget: f64,
|
||||
thresholds: BudgetAlertThresholds,
|
||||
) -> BudgetState {
|
||||
pub(crate) fn budget_state(used: f64, budget: f64) -> BudgetState {
|
||||
if budget <= 0.0 {
|
||||
BudgetState::Unconfigured
|
||||
} else if used / budget >= 1.0 {
|
||||
BudgetState::OverBudget
|
||||
} else if used / budget >= thresholds.critical {
|
||||
BudgetState::Alert90
|
||||
} else if used / budget >= thresholds.warning {
|
||||
BudgetState::Alert75
|
||||
} else if used / budget >= thresholds.advisory {
|
||||
BudgetState::Alert50
|
||||
} else if used / budget >= WARNING_THRESHOLD {
|
||||
BudgetState::Warning
|
||||
} else {
|
||||
BudgetState::Normal
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn gradient_color(ratio: f64, thresholds: BudgetAlertThresholds) -> Color {
|
||||
pub(crate) fn gradient_color(ratio: f64) -> Color {
|
||||
const GREEN: (u8, u8, u8) = (34, 197, 94);
|
||||
const YELLOW: (u8, u8, u8) = (234, 179, 8);
|
||||
const RED: (u8, u8, u8) = (239, 68, 68);
|
||||
|
||||
let clamped = ratio.clamp(0.0, 1.0);
|
||||
if clamped <= thresholds.warning {
|
||||
interpolate_rgb(
|
||||
GREEN,
|
||||
YELLOW,
|
||||
clamped / thresholds.warning.max(f64::EPSILON),
|
||||
)
|
||||
if clamped <= WARNING_THRESHOLD {
|
||||
interpolate_rgb(GREEN, YELLOW, clamped / WARNING_THRESHOLD)
|
||||
} else {
|
||||
interpolate_rgb(
|
||||
YELLOW,
|
||||
RED,
|
||||
(clamped - thresholds.warning) / (1.0 - thresholds.warning),
|
||||
(clamped - WARNING_THRESHOLD) / (1.0 - WARNING_THRESHOLD),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn threshold_label(value: f64) -> String {
|
||||
format!("{}%", (value * 100.0).round() as u64)
|
||||
}
|
||||
|
||||
pub(crate) fn format_currency(value: f64) -> String {
|
||||
format!("${value:.2}")
|
||||
}
|
||||
@@ -296,76 +246,25 @@ fn interpolate_rgb(from: (u8, u8, u8), to: (u8, u8, u8), ratio: f64) -> Color {
|
||||
mod tests {
|
||||
use ratatui::{buffer::Buffer, layout::Rect, style::Color, widgets::Widget};
|
||||
|
||||
use crate::config::{BudgetAlertThresholds, Config};
|
||||
|
||||
use super::{gradient_color, threshold_label, BudgetState, TokenMeter};
|
||||
use super::{gradient_color, BudgetState, TokenMeter};
|
||||
|
||||
#[test]
|
||||
fn budget_state_uses_alert_threshold_ladder() {
|
||||
assert_eq!(
|
||||
TokenMeter::tokens("Token Budget", 50, 100, Config::BUDGET_ALERT_THRESHOLDS).state(),
|
||||
BudgetState::Alert50
|
||||
);
|
||||
assert_eq!(
|
||||
TokenMeter::tokens("Token Budget", 75, 100, Config::BUDGET_ALERT_THRESHOLDS).state(),
|
||||
BudgetState::Alert75
|
||||
);
|
||||
assert_eq!(
|
||||
TokenMeter::tokens("Token Budget", 90, 100, Config::BUDGET_ALERT_THRESHOLDS).state(),
|
||||
BudgetState::Alert90
|
||||
);
|
||||
assert_eq!(
|
||||
TokenMeter::tokens("Token Budget", 100, 100, Config::BUDGET_ALERT_THRESHOLDS).state(),
|
||||
BudgetState::OverBudget
|
||||
);
|
||||
fn warning_state_starts_at_eighty_percent() {
|
||||
let meter = TokenMeter::tokens("Token Budget", 80, 100);
|
||||
|
||||
assert_eq!(meter.state(), BudgetState::Warning);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_runs_from_green_to_yellow_to_red() {
|
||||
assert_eq!(
|
||||
gradient_color(0.0, Config::BUDGET_ALERT_THRESHOLDS),
|
||||
Color::Rgb(34, 197, 94)
|
||||
);
|
||||
assert_eq!(
|
||||
gradient_color(0.75, Config::BUDGET_ALERT_THRESHOLDS),
|
||||
Color::Rgb(234, 179, 8)
|
||||
);
|
||||
assert_eq!(
|
||||
gradient_color(1.0, Config::BUDGET_ALERT_THRESHOLDS),
|
||||
Color::Rgb(239, 68, 68)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_meter_uses_custom_budget_thresholds() {
|
||||
let meter = TokenMeter::tokens(
|
||||
"Token Budget",
|
||||
45,
|
||||
100,
|
||||
BudgetAlertThresholds {
|
||||
advisory: 0.40,
|
||||
warning: 0.70,
|
||||
critical: 0.85,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(meter.state(), BudgetState::Alert50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threshold_label_rounds_to_percent() {
|
||||
assert_eq!(threshold_label(0.4), "40%");
|
||||
assert_eq!(threshold_label(0.875), "88%");
|
||||
assert_eq!(gradient_color(0.0), Color::Rgb(34, 197, 94));
|
||||
assert_eq!(gradient_color(0.8), Color::Rgb(234, 179, 8));
|
||||
assert_eq!(gradient_color(1.0), Color::Rgb(239, 68, 68));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_meter_renders_compact_usage_label() {
|
||||
let meter = TokenMeter::tokens(
|
||||
"Token Budget",
|
||||
4_000,
|
||||
10_000,
|
||||
Config::BUDGET_ALERT_THRESHOLDS,
|
||||
);
|
||||
let meter = TokenMeter::tokens("Token Budget", 4_000, 10_000);
|
||||
let area = Rect::new(0, 0, 48, 2);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
|
||||
|
||||
+53
-1882
File diff suppressed because it is too large
Load Diff
+1
-13
@@ -7,7 +7,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"pre:bash:block-no-verify\" \"scripts/hooks/block-no-verify.js\" \"minimal,standard,strict\""
|
||||
"command": "npx block-no-verify@1.1.2"
|
||||
}
|
||||
],
|
||||
"description": "Block git hook-bypass flag to protect pre-commit, commit-msg, and pre-push hooks from being skipped",
|
||||
@@ -260,18 +260,6 @@
|
||||
"description": "Capture governance events from tool outputs. Enable with ECC_GOVERNANCE_CAPTURE=1",
|
||||
"id": "post:governance-capture"
|
||||
},
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"post:session-activity-tracker\" \"scripts/hooks/session-activity-tracker.js\" \"standard,strict\"",
|
||||
"timeout": 10
|
||||
}
|
||||
],
|
||||
"description": "Track per-session tool calls and file activity for ECC2 metrics",
|
||||
"id": "post:session-activity-tracker"
|
||||
},
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
|
||||
@@ -90,7 +90,6 @@
|
||||
".gemini",
|
||||
".opencode",
|
||||
"mcp-configs",
|
||||
"scripts/auto-update.js",
|
||||
"scripts/setup-package-manager.js"
|
||||
],
|
||||
"targets": [
|
||||
|
||||
+1
-5
@@ -50,7 +50,6 @@
|
||||
".opencode/index.ts",
|
||||
".opencode/opencode.json",
|
||||
".opencode/package.json",
|
||||
".opencode/package-lock.json",
|
||||
".opencode/tsconfig.json",
|
||||
".opencode/MIGRATION.md",
|
||||
".opencode/README.md",
|
||||
@@ -91,17 +90,14 @@
|
||||
"scripts/uninstall.js",
|
||||
"skills/",
|
||||
"AGENTS.md",
|
||||
"agent.yaml",
|
||||
".claude-plugin/plugin.json",
|
||||
".claude-plugin/marketplace.json",
|
||||
".claude-plugin/README.md",
|
||||
".codex-plugin/plugin.json",
|
||||
".codex-plugin/README.md",
|
||||
".mcp.json",
|
||||
"install.sh",
|
||||
"install.ps1",
|
||||
"llms.txt",
|
||||
"VERSION"
|
||||
"llms.txt"
|
||||
],
|
||||
"bin": {
|
||||
"ecc": "scripts/ecc.js",
|
||||
|
||||
@@ -1,361 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const { discoverInstalledStates } = require('./lib/install-lifecycle');
|
||||
const { SUPPORTED_INSTALL_TARGETS } = require('./lib/install-manifests');
|
||||
|
||||
function showHelp(exitCode = 0) {
|
||||
console.log(`
|
||||
Usage: node scripts/auto-update.js [--target <${SUPPORTED_INSTALL_TARGETS.join('|')}>] [--repo-root <path>] [--dry-run] [--json]
|
||||
|
||||
Pull the latest ECC repo changes and reinstall the current context's managed targets
|
||||
using the original install-state request.
|
||||
`);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = argv.slice(2);
|
||||
const parsed = {
|
||||
targets: [],
|
||||
repoRoot: null,
|
||||
dryRun: false,
|
||||
json: false,
|
||||
help: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
|
||||
if (arg === '--target') {
|
||||
parsed.targets.push(args[index + 1] || null);
|
||||
index += 1;
|
||||
} else if (arg === '--repo-root') {
|
||||
parsed.repoRoot = args[index + 1] || null;
|
||||
index += 1;
|
||||
} else if (arg === '--dry-run') {
|
||||
parsed.dryRun = true;
|
||||
} else if (arg === '--json') {
|
||||
parsed.json = true;
|
||||
} else if (arg === '--help' || arg === '-h') {
|
||||
parsed.help = true;
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function deriveRepoRootFromState(state) {
|
||||
const operations = Array.isArray(state && state.operations) ? state.operations : [];
|
||||
|
||||
for (const operation of operations) {
|
||||
if (typeof operation.sourcePath !== 'string' || !operation.sourcePath.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof operation.sourceRelativePath !== 'string' || !operation.sourceRelativePath.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const relativeParts = operation.sourceRelativePath
|
||||
.split(/[\\/]+/)
|
||||
.filter(Boolean);
|
||||
|
||||
if (relativeParts.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let repoRoot = path.resolve(operation.sourcePath);
|
||||
for (let index = 0; index < relativeParts.length; index += 1) {
|
||||
repoRoot = path.dirname(repoRoot);
|
||||
}
|
||||
|
||||
return repoRoot;
|
||||
}
|
||||
|
||||
throw new Error('Unable to infer ECC repo root from install-state operations');
|
||||
}
|
||||
|
||||
function buildInstallApplyArgs(record) {
|
||||
const state = record.state;
|
||||
const target = state.target.target || record.adapter.target;
|
||||
const request = state.request || {};
|
||||
const args = [];
|
||||
|
||||
if (target) {
|
||||
args.push('--target', target);
|
||||
}
|
||||
|
||||
if (request.profile) {
|
||||
args.push('--profile', request.profile);
|
||||
}
|
||||
|
||||
if (Array.isArray(request.modules) && request.modules.length > 0) {
|
||||
args.push('--modules', request.modules.join(','));
|
||||
}
|
||||
|
||||
for (const componentId of Array.isArray(request.includeComponents) ? request.includeComponents : []) {
|
||||
args.push('--with', componentId);
|
||||
}
|
||||
|
||||
for (const componentId of Array.isArray(request.excludeComponents) ? request.excludeComponents : []) {
|
||||
args.push('--without', componentId);
|
||||
}
|
||||
|
||||
for (const language of Array.isArray(request.legacyLanguages) ? request.legacyLanguages : []) {
|
||||
args.push(language);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
function determineInstallCwd(record, repoRoot) {
|
||||
if (record.adapter.kind === 'project') {
|
||||
return path.dirname(record.state.target.root);
|
||||
}
|
||||
|
||||
return repoRoot;
|
||||
}
|
||||
|
||||
function validateRepoRoot(repoRoot) {
|
||||
const normalized = path.resolve(repoRoot);
|
||||
const packageJsonPath = path.join(normalized, 'package.json');
|
||||
const installApplyPath = path.join(normalized, 'scripts', 'install-apply.js');
|
||||
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
throw new Error(`Invalid ECC repo root: missing package.json at ${packageJsonPath}`);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(installApplyPath)) {
|
||||
throw new Error(`Invalid ECC repo root: missing install script at ${installApplyPath}`);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function runExternalCommand(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env || process.env,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
if (typeof result.status === 'number' && result.status !== 0) {
|
||||
const errorOutput = (result.stderr || result.stdout || '').trim();
|
||||
throw new Error(`${command} ${args.join(' ')} failed${errorOutput ? `: ${errorOutput}` : ''}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function runAutoUpdate(options = {}, dependencies = {}) {
|
||||
const discover = dependencies.discoverInstalledStates || discoverInstalledStates;
|
||||
const execute = dependencies.runExternalCommand || runExternalCommand;
|
||||
const homeDir = options.homeDir || process.env.HOME || os.homedir();
|
||||
const projectRoot = options.projectRoot || process.cwd();
|
||||
const requestedRepoRoot = options.repoRoot ? validateRepoRoot(options.repoRoot) : null;
|
||||
const records = discover({
|
||||
homeDir,
|
||||
projectRoot,
|
||||
targets: options.targets,
|
||||
}).filter(record => record.exists);
|
||||
|
||||
const results = [];
|
||||
if (records.length === 0) {
|
||||
return {
|
||||
dryRun: Boolean(options.dryRun),
|
||||
repoRoot: requestedRepoRoot,
|
||||
results,
|
||||
summary: {
|
||||
checkedCount: 0,
|
||||
updatedCount: 0,
|
||||
errorCount: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const validRecords = [];
|
||||
const inferredRepoRoots = [];
|
||||
for (const record of records) {
|
||||
if (record.error || !record.state) {
|
||||
results.push({
|
||||
adapter: record.adapter,
|
||||
installStatePath: record.installStatePath,
|
||||
status: 'error',
|
||||
error: record.error || 'No valid install-state available',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const recordRepoRoot = requestedRepoRoot || validateRepoRoot(deriveRepoRootFromState(record.state));
|
||||
inferredRepoRoots.push(recordRepoRoot);
|
||||
validRecords.push({
|
||||
record,
|
||||
repoRoot: recordRepoRoot,
|
||||
});
|
||||
}
|
||||
|
||||
if (!requestedRepoRoot) {
|
||||
const uniqueRepoRoots = [...new Set(inferredRepoRoots)];
|
||||
if (uniqueRepoRoots.length > 1) {
|
||||
throw new Error(`Multiple ECC repo roots detected: ${uniqueRepoRoots.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
const repoRoot = requestedRepoRoot || inferredRepoRoots[0] || null;
|
||||
if (!repoRoot) {
|
||||
return {
|
||||
dryRun: Boolean(options.dryRun),
|
||||
repoRoot,
|
||||
results,
|
||||
summary: {
|
||||
checkedCount: results.length,
|
||||
updatedCount: 0,
|
||||
errorCount: results.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: homeDir,
|
||||
USERPROFILE: homeDir,
|
||||
};
|
||||
|
||||
if (!options.dryRun) {
|
||||
execute('git', ['fetch', '--all', '--prune'], { cwd: repoRoot, env });
|
||||
execute('git', ['pull', '--ff-only'], { cwd: repoRoot, env });
|
||||
}
|
||||
|
||||
for (const entry of validRecords) {
|
||||
const installArgs = buildInstallApplyArgs(entry.record);
|
||||
const args = [
|
||||
path.join(repoRoot, 'scripts', 'install-apply.js'),
|
||||
...installArgs,
|
||||
'--json',
|
||||
];
|
||||
|
||||
if (options.dryRun) {
|
||||
args.push('--dry-run');
|
||||
}
|
||||
|
||||
try {
|
||||
const commandResult = execute(process.execPath, args, {
|
||||
cwd: determineInstallCwd(entry.record, repoRoot),
|
||||
env,
|
||||
});
|
||||
|
||||
let payload = null;
|
||||
if (commandResult.stdout && commandResult.stdout.trim()) {
|
||||
payload = JSON.parse(commandResult.stdout);
|
||||
}
|
||||
|
||||
results.push({
|
||||
adapter: entry.record.adapter,
|
||||
installStatePath: entry.record.installStatePath,
|
||||
repoRoot,
|
||||
cwd: determineInstallCwd(entry.record, repoRoot),
|
||||
installArgs,
|
||||
status: options.dryRun ? 'planned' : 'updated',
|
||||
payload,
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
adapter: entry.record.adapter,
|
||||
installStatePath: entry.record.installStatePath,
|
||||
repoRoot,
|
||||
installArgs,
|
||||
status: 'error',
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
dryRun: Boolean(options.dryRun),
|
||||
repoRoot,
|
||||
results,
|
||||
summary: {
|
||||
checkedCount: results.length,
|
||||
updatedCount: results.filter(result => result.status === 'updated' || result.status === 'planned').length,
|
||||
errorCount: results.filter(result => result.status === 'error').length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function printHuman(result) {
|
||||
if (result.results.length === 0) {
|
||||
console.log('No ECC install-state files found for the current home/project context.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`${result.dryRun ? 'Auto-update dry run' : 'Auto-update summary'}:\n`);
|
||||
if (result.repoRoot) {
|
||||
console.log(`Repo root: ${result.repoRoot}\n`);
|
||||
}
|
||||
|
||||
for (const entry of result.results) {
|
||||
console.log(`- ${entry.adapter.id}`);
|
||||
console.log(` Status: ${entry.status.toUpperCase()}`);
|
||||
console.log(` Install-state: ${entry.installStatePath}`);
|
||||
if (entry.error) {
|
||||
console.log(` Error: ${entry.error}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(` Reinstall args: ${entry.installArgs.join(' ') || '(none)'}`);
|
||||
}
|
||||
|
||||
console.log(`\nSummary: checked=${result.summary.checkedCount}, ${result.dryRun ? 'planned' : 'updated'}=${result.summary.updatedCount}, errors=${result.summary.errorCount}`);
|
||||
}
|
||||
|
||||
function main() {
|
||||
try {
|
||||
const options = parseArgs(process.argv);
|
||||
if (options.help) {
|
||||
showHelp(0);
|
||||
}
|
||||
|
||||
const result = runAutoUpdate({
|
||||
homeDir: process.env.HOME || os.homedir(),
|
||||
projectRoot: process.cwd(),
|
||||
targets: options.targets,
|
||||
repoRoot: options.repoRoot,
|
||||
dryRun: options.dryRun,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
printHuman(result);
|
||||
}
|
||||
|
||||
process.exitCode = result.summary.errorCount > 0 ? 1 : 0;
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseArgs,
|
||||
deriveRepoRootFromState,
|
||||
buildInstallApplyArgs,
|
||||
determineInstallCwd,
|
||||
runAutoUpdate,
|
||||
};
|
||||
@@ -1,138 +0,0 @@
|
||||
#!/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,
|
||||
};
|
||||
@@ -33,10 +33,6 @@ const COMMANDS = {
|
||||
script: 'repair.js',
|
||||
description: 'Restore drifted or missing ECC-managed files',
|
||||
},
|
||||
'auto-update': {
|
||||
script: 'auto-update.js',
|
||||
description: 'Pull latest ECC changes and reinstall the current managed targets',
|
||||
},
|
||||
status: {
|
||||
script: 'status.js',
|
||||
description: 'Query the ECC SQLite state store status summary',
|
||||
@@ -62,7 +58,6 @@ const PRIMARY_COMMANDS = [
|
||||
'list-installed',
|
||||
'doctor',
|
||||
'repair',
|
||||
'auto-update',
|
||||
'status',
|
||||
'sessions',
|
||||
'session-inspect',
|
||||
@@ -95,7 +90,6 @@ Examples:
|
||||
ecc list-installed --json
|
||||
ecc doctor --target cursor
|
||||
ecc repair --dry-run
|
||||
ecc auto-update --dry-run
|
||||
ecc status --json
|
||||
ecc sessions
|
||||
ecc sessions session-active --json
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
#!/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);
|
||||
});
|
||||
}
|
||||
@@ -55,7 +55,7 @@ process.stdin.on('end', () => {
|
||||
const outputTokens = toNumber(usage.output_tokens || usage.completion_tokens || 0);
|
||||
|
||||
const model = String(input.model || input._cursor?.model || process.env.CLAUDE_MODEL || 'unknown');
|
||||
const sessionId = String(process.env.ECC_SESSION_ID || process.env.CLAUDE_SESSION_ID || 'default');
|
||||
const sessionId = String(process.env.CLAUDE_SESSION_ID || 'default');
|
||||
|
||||
const metricsDir = path.join(getClaudeDir(), 'metrics');
|
||||
ensureDir(metricsDir);
|
||||
|
||||
@@ -1,639 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Session Activity Tracker Hook
|
||||
*
|
||||
* PostToolUse hook that records sanitized per-tool activity to
|
||||
* ~/.claude/metrics/tool-usage.jsonl for ECC2 metric sync.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
const {
|
||||
appendFile,
|
||||
getClaudeDir,
|
||||
stripAnsi,
|
||||
} = require('../lib/utils');
|
||||
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
const METRICS_FILE_NAME = 'tool-usage.jsonl';
|
||||
const FILE_PATH_KEYS = new Set([
|
||||
'file_path',
|
||||
'file_paths',
|
||||
'source_path',
|
||||
'destination_path',
|
||||
'old_file_path',
|
||||
'new_file_path',
|
||||
]);
|
||||
|
||||
function redactSecrets(value) {
|
||||
return String(value || '')
|
||||
.replace(/\n/g, ' ')
|
||||
.replace(/--token[= ][^ ]*/g, '--token=<REDACTED>')
|
||||
.replace(/Authorization:[: ]*[^ ]*[: ]*[^ ]*/gi, 'Authorization:<REDACTED>')
|
||||
.replace(/\bAKIA[A-Z0-9]{16}\b/g, '<REDACTED>')
|
||||
.replace(/\bASIA[A-Z0-9]{16}\b/g, '<REDACTED>')
|
||||
.replace(/password[= ][^ ]*/gi, 'password=<REDACTED>')
|
||||
.replace(/\bghp_[A-Za-z0-9_]+\b/g, '<REDACTED>')
|
||||
.replace(/\bgho_[A-Za-z0-9_]+\b/g, '<REDACTED>')
|
||||
.replace(/\bghs_[A-Za-z0-9_]+\b/g, '<REDACTED>')
|
||||
.replace(/\bgithub_pat_[A-Za-z0-9_]+\b/g, '<REDACTED>');
|
||||
}
|
||||
|
||||
function truncateSummary(value, maxLength = 220) {
|
||||
const normalized = stripAnsi(redactSecrets(value)).trim().replace(/\s+/g, ' ');
|
||||
if (normalized.length <= maxLength) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, maxLength - 3)}...`;
|
||||
}
|
||||
|
||||
function sanitizeParamValue(value, depth = 0) {
|
||||
if (depth >= 4) {
|
||||
return '[Truncated]';
|
||||
}
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return truncateSummary(value, 160);
|
||||
}
|
||||
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.slice(0, 8).map(entry => sanitizeParamValue(entry, depth + 1));
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
const output = {};
|
||||
for (const [key, nested] of Object.entries(value).slice(0, 20)) {
|
||||
output[key] = sanitizeParamValue(nested, depth + 1);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
return truncateSummary(String(value), 160);
|
||||
}
|
||||
|
||||
function sanitizeInputParams(toolInput) {
|
||||
if (!toolInput || typeof toolInput !== 'object' || Array.isArray(toolInput)) {
|
||||
return '{}';
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(sanitizeParamValue(toolInput));
|
||||
} catch {
|
||||
return '{}';
|
||||
}
|
||||
}
|
||||
|
||||
function pushPathCandidate(paths, value) {
|
||||
const candidate = String(value || '').trim();
|
||||
if (!candidate) {
|
||||
return;
|
||||
}
|
||||
if (/^(https?:\/\/|app:\/\/|plugin:\/\/|mcp:\/\/)/i.test(candidate)) {
|
||||
return;
|
||||
}
|
||||
if (!paths.includes(candidate)) {
|
||||
paths.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
function pushFileEvent(events, value, action, diffPreview, patchPreview) {
|
||||
const candidate = String(value || '').trim();
|
||||
if (!candidate) {
|
||||
return;
|
||||
}
|
||||
if (/^(https?:\/\/|app:\/\/|plugin:\/\/|mcp:\/\/)/i.test(candidate)) {
|
||||
return;
|
||||
}
|
||||
const normalizedDiffPreview = typeof diffPreview === 'string' && diffPreview.trim()
|
||||
? diffPreview.trim()
|
||||
: undefined;
|
||||
const normalizedPatchPreview = typeof patchPreview === 'string' && patchPreview.trim()
|
||||
? patchPreview.trim()
|
||||
: undefined;
|
||||
if (!events.some(event =>
|
||||
event.path === candidate
|
||||
&& event.action === action
|
||||
&& (event.diff_preview || undefined) === normalizedDiffPreview
|
||||
&& (event.patch_preview || undefined) === normalizedPatchPreview
|
||||
)) {
|
||||
const event = { path: candidate, action };
|
||||
if (normalizedDiffPreview) {
|
||||
event.diff_preview = normalizedDiffPreview;
|
||||
}
|
||||
if (normalizedPatchPreview) {
|
||||
event.patch_preview = normalizedPatchPreview;
|
||||
}
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeDiffText(value, maxLength = 96) {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
return '';
|
||||
}
|
||||
return truncateSummary(value, maxLength);
|
||||
}
|
||||
|
||||
function sanitizePatchLines(value, maxLines = 4, maxLineLength = 120) {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return stripAnsi(redactSecrets(value))
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, maxLines)
|
||||
.map(line => line.length <= maxLineLength ? line : `${line.slice(0, maxLineLength - 3)}...`);
|
||||
}
|
||||
|
||||
function buildReplacementPreview(oldValue, newValue) {
|
||||
const before = sanitizeDiffText(oldValue);
|
||||
const after = sanitizeDiffText(newValue);
|
||||
if (!before && !after) {
|
||||
return undefined;
|
||||
}
|
||||
if (!before) {
|
||||
return `-> ${after}`;
|
||||
}
|
||||
if (!after) {
|
||||
return `${before} ->`;
|
||||
}
|
||||
return `${before} -> ${after}`;
|
||||
}
|
||||
|
||||
function buildCreationPreview(content) {
|
||||
const normalized = sanitizeDiffText(content);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
return `+ ${normalized}`;
|
||||
}
|
||||
|
||||
function buildPatchPreviewFromReplacement(oldValue, newValue) {
|
||||
const beforeLines = sanitizePatchLines(oldValue);
|
||||
const afterLines = sanitizePatchLines(newValue);
|
||||
if (beforeLines.length === 0 && afterLines.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lines = ['@@'];
|
||||
for (const line of beforeLines) {
|
||||
lines.push(`- ${line}`);
|
||||
}
|
||||
for (const line of afterLines) {
|
||||
lines.push(`+ ${line}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function buildPatchPreviewFromContent(content, prefix) {
|
||||
const lines = sanitizePatchLines(content);
|
||||
if (lines.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return lines.map(line => `${prefix} ${line}`).join('\n');
|
||||
}
|
||||
|
||||
function buildDiffPreviewFromPatchPreview(patchPreview) {
|
||||
if (typeof patchPreview !== 'string' || !patchPreview.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lines = patchPreview
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean);
|
||||
const removed = lines.find(line => line.startsWith('- ') || line.startsWith('-'));
|
||||
const added = lines.find(line => line.startsWith('+ ') || line.startsWith('+'));
|
||||
|
||||
if (!removed && !added) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const before = removed ? removed.replace(/^- ?/, '') : '';
|
||||
const after = added ? added.replace(/^\+ ?/, '') : '';
|
||||
if (before && after) {
|
||||
return `${before} -> ${after}`;
|
||||
}
|
||||
if (before) {
|
||||
return `${before} ->`;
|
||||
}
|
||||
return `-> ${after}`;
|
||||
}
|
||||
|
||||
function inferDefaultFileAction(toolName) {
|
||||
const normalized = String(toolName || '').trim().toLowerCase();
|
||||
if (normalized.includes('read')) {
|
||||
return 'read';
|
||||
}
|
||||
if (normalized.includes('write')) {
|
||||
return 'create';
|
||||
}
|
||||
if (normalized.includes('edit')) {
|
||||
return 'modify';
|
||||
}
|
||||
if (normalized.includes('delete') || normalized.includes('remove')) {
|
||||
return 'delete';
|
||||
}
|
||||
if (normalized.includes('move') || normalized.includes('rename')) {
|
||||
return 'move';
|
||||
}
|
||||
return 'touch';
|
||||
}
|
||||
|
||||
function actionForFileKey(toolName, key) {
|
||||
if (key === 'source_path' || key === 'old_file_path') {
|
||||
return 'move';
|
||||
}
|
||||
if (key === 'destination_path' || key === 'new_file_path') {
|
||||
return 'move';
|
||||
}
|
||||
return inferDefaultFileAction(toolName);
|
||||
}
|
||||
|
||||
function collectFilePaths(value, paths) {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
collectFilePaths(entry, paths);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
pushPathCandidate(paths, value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, nested] of Object.entries(value)) {
|
||||
if (FILE_PATH_KEYS.has(key)) {
|
||||
collectFilePaths(nested, paths);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nested && (Array.isArray(nested) || typeof nested === 'object')) {
|
||||
collectFilePaths(nested, paths);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractFilePaths(toolInput) {
|
||||
const paths = [];
|
||||
if (!toolInput || typeof toolInput !== 'object') {
|
||||
return paths;
|
||||
}
|
||||
collectFilePaths(toolInput, paths);
|
||||
return paths;
|
||||
}
|
||||
|
||||
function fileEventDiffPreview(toolName, value, action) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof value.old_string === 'string' || typeof value.new_string === 'string') {
|
||||
return buildReplacementPreview(value.old_string, value.new_string);
|
||||
}
|
||||
|
||||
if (action === 'create') {
|
||||
return buildCreationPreview(value.content || value.file_text || value.text);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function fileEventPatchPreview(value, action) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof value.old_string === 'string' || typeof value.new_string === 'string') {
|
||||
return buildPatchPreviewFromReplacement(value.old_string, value.new_string);
|
||||
}
|
||||
|
||||
if (action === 'create') {
|
||||
return buildPatchPreviewFromContent(value.content || value.file_text || value.text, '+');
|
||||
}
|
||||
|
||||
if (action === 'delete') {
|
||||
return buildPatchPreviewFromContent(value.content || value.old_string || value.file_text, '-');
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function runGit(args, cwd) {
|
||||
const result = spawnSync('git', args, {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
timeout: 2500,
|
||||
});
|
||||
|
||||
if (result.error || result.status !== 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return String(result.stdout || '').trim();
|
||||
}
|
||||
|
||||
function gitRepoRoot(cwd) {
|
||||
return runGit(['rev-parse', '--show-toplevel'], cwd);
|
||||
}
|
||||
|
||||
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 candidates;
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
function enrichFileEventFromWorkingTree(toolName, event) {
|
||||
if (!event || typeof event !== 'object' || !event.path) {
|
||||
return event;
|
||||
}
|
||||
|
||||
const repoRoot = gitRepoRoot(process.cwd());
|
||||
if (!repoRoot) {
|
||||
return event;
|
||||
}
|
||||
|
||||
const pathCandidates = candidateGitPaths(repoRoot, event.path);
|
||||
if (pathCandidates.length === 0) {
|
||||
return event;
|
||||
}
|
||||
|
||||
const tool = String(toolName || '').trim().toLowerCase();
|
||||
const tracked = trackedInGit(repoRoot, pathCandidates);
|
||||
const patchPreview = patchPreviewFromGitDiff(repoRoot, pathCandidates) || event.patch_preview;
|
||||
const diffPreview = buildDiffPreviewFromPatchPreview(patchPreview) || event.diff_preview;
|
||||
|
||||
if (tool.includes('write')) {
|
||||
return {
|
||||
...event,
|
||||
action: tracked ? 'modify' : event.action,
|
||||
diff_preview: diffPreview,
|
||||
patch_preview: patchPreview,
|
||||
};
|
||||
}
|
||||
|
||||
if (tracked && patchPreview) {
|
||||
return {
|
||||
...event,
|
||||
diff_preview: diffPreview,
|
||||
patch_preview: patchPreview,
|
||||
};
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
function collectFileEvents(toolName, value, events, key = null, parentValue = null) {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
collectFileEvents(toolName, entry, events, key, parentValue);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
if (key && FILE_PATH_KEYS.has(key)) {
|
||||
const action = actionForFileKey(toolName, key);
|
||||
pushFileEvent(
|
||||
events,
|
||||
value,
|
||||
action,
|
||||
fileEventDiffPreview(toolName, parentValue, action),
|
||||
fileEventPatchPreview(parentValue, action)
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [nestedKey, nested] of Object.entries(value)) {
|
||||
if (FILE_PATH_KEYS.has(nestedKey)) {
|
||||
collectFileEvents(toolName, nested, events, nestedKey, value);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nested && (Array.isArray(nested) || typeof nested === 'object')) {
|
||||
collectFileEvents(toolName, nested, events, null, nested);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractFileEvents(toolName, toolInput) {
|
||||
const events = [];
|
||||
if (!toolInput || typeof toolInput !== 'object') {
|
||||
return events;
|
||||
}
|
||||
collectFileEvents(toolName, toolInput, events);
|
||||
return events;
|
||||
}
|
||||
|
||||
function summarizeInput(toolName, toolInput, filePaths) {
|
||||
if (toolName === 'Bash') {
|
||||
return truncateSummary(toolInput?.command || 'bash');
|
||||
}
|
||||
|
||||
if (filePaths.length > 0) {
|
||||
return truncateSummary(`${toolName} ${filePaths.join(', ')}`);
|
||||
}
|
||||
|
||||
if (toolInput && typeof toolInput === 'object') {
|
||||
const shallow = {};
|
||||
for (const [key, value] of Object.entries(toolInput)) {
|
||||
if (value === null || value === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
shallow[key] = value;
|
||||
}
|
||||
}
|
||||
const serialized = Object.keys(shallow).length > 0 ? JSON.stringify(shallow) : toolName;
|
||||
return truncateSummary(serialized);
|
||||
}
|
||||
|
||||
return truncateSummary(toolName);
|
||||
}
|
||||
|
||||
function summarizeOutput(toolOutput) {
|
||||
if (toolOutput === null || toolOutput === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof toolOutput === 'string') {
|
||||
return truncateSummary(toolOutput);
|
||||
}
|
||||
|
||||
if (typeof toolOutput === 'object' && typeof toolOutput.output === 'string') {
|
||||
return truncateSummary(toolOutput.output);
|
||||
}
|
||||
|
||||
return truncateSummary(JSON.stringify(toolOutput));
|
||||
}
|
||||
|
||||
function buildActivityRow(input, env = process.env) {
|
||||
const hookEvent = String(env.CLAUDE_HOOK_EVENT_NAME || '').trim();
|
||||
if (hookEvent && hookEvent !== 'PostToolUse') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toolName = String(input?.tool_name || '').trim();
|
||||
const sessionId = String(env.ECC_SESSION_ID || env.CLAUDE_SESSION_ID || '').trim();
|
||||
if (!toolName || !sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toolInput = input?.tool_input || {};
|
||||
const fileEvents = extractFileEvents(toolName, toolInput).map(event =>
|
||||
enrichFileEventFromWorkingTree(toolName, event)
|
||||
);
|
||||
const filePaths = fileEvents.length > 0
|
||||
? [...new Set(fileEvents.map(event => event.path))]
|
||||
: extractFilePaths(toolInput);
|
||||
|
||||
return {
|
||||
id: `tool-${Date.now()}-${crypto.randomBytes(6).toString('hex')}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
session_id: sessionId,
|
||||
tool_name: toolName,
|
||||
input_summary: summarizeInput(toolName, toolInput, filePaths),
|
||||
input_params_json: sanitizeInputParams(toolInput),
|
||||
output_summary: summarizeOutput(input?.tool_output),
|
||||
duration_ms: 0,
|
||||
file_paths: filePaths,
|
||||
file_events: fileEvents,
|
||||
};
|
||||
}
|
||||
|
||||
function run(rawInput) {
|
||||
try {
|
||||
const input = rawInput.trim() ? JSON.parse(rawInput) : {};
|
||||
const row = buildActivityRow(input);
|
||||
if (row) {
|
||||
appendFile(
|
||||
path.join(getClaudeDir(), 'metrics', METRICS_FILE_NAME),
|
||||
`${JSON.stringify(row)}\n`
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Keep hook non-blocking.
|
||||
}
|
||||
|
||||
return rawInput;
|
||||
}
|
||||
|
||||
function main() {
|
||||
let raw = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
if (raw.length < MAX_STDIN) {
|
||||
const remaining = MAX_STDIN - raw.length;
|
||||
raw += chunk.substring(0, remaining);
|
||||
}
|
||||
});
|
||||
process.stdin.on('end', () => {
|
||||
process.stdout.write(run(raw));
|
||||
});
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildActivityRow,
|
||||
extractFileEvents,
|
||||
extractFilePaths,
|
||||
summarizeInput,
|
||||
summarizeOutput,
|
||||
run,
|
||||
};
|
||||
@@ -46,6 +46,80 @@ 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;
|
||||
@@ -94,7 +168,7 @@ function buildFilteredMcpWrites(plan) {
|
||||
return writes;
|
||||
}
|
||||
|
||||
function buildResolvedClaudeHooks(plan) {
|
||||
function buildMergedSettings(plan) {
|
||||
if (!plan.adapter || plan.adapter.target !== 'claude') {
|
||||
return null;
|
||||
}
|
||||
@@ -107,22 +181,46 @@ function buildResolvedClaudeHooks(plan) {
|
||||
}
|
||||
|
||||
const hooksConfig = readJsonObject(hooksSourcePath, 'hooks config');
|
||||
const resolvedHooks = replacePluginRootPlaceholders(hooksConfig.hooks, pluginRoot);
|
||||
if (!resolvedHooks || typeof resolvedHooks !== 'object' || Array.isArray(resolvedHooks)) {
|
||||
const incomingHooks = replacePluginRootPlaceholders(hooksConfig.hooks, pluginRoot);
|
||||
if (!incomingHooks || typeof incomingHooks !== 'object' || Array.isArray(incomingHooks)) {
|
||||
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: resolvedHooks,
|
||||
hooks: incomingHooks,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function applyInstallPlan(plan) {
|
||||
const resolvedClaudeHooksPlan = buildResolvedClaudeHooks(plan);
|
||||
const mergedSettingsPlan = buildMergedSettings(plan);
|
||||
const filteredMcpWrites = buildFilteredMcpWrites(plan);
|
||||
|
||||
for (const operation of plan.operations) {
|
||||
@@ -130,11 +228,17 @@ function applyInstallPlan(plan) {
|
||||
fs.copyFileSync(operation.sourcePath, operation.destinationPath);
|
||||
}
|
||||
|
||||
if (resolvedClaudeHooksPlan) {
|
||||
fs.mkdirSync(path.dirname(resolvedClaudeHooksPlan.hooksDestinationPath), { recursive: true });
|
||||
if (mergedSettingsPlan) {
|
||||
fs.mkdirSync(path.dirname(mergedSettingsPlan.hooksDestinationPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
resolvedClaudeHooksPlan.hooksDestinationPath,
|
||||
JSON.stringify(resolvedClaudeHooksPlan.resolvedHooksConfig, null, 2) + '\n',
|
||||
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',
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
+11
-172
@@ -6,21 +6,9 @@ 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"
|
||||
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() {
|
||||
@@ -48,14 +36,14 @@ if [[ "$CURRENT_BRANCH" != "main" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check working tree is clean, including untracked files
|
||||
if [[ -n "$(git status --porcelain --untracked-files=all)" ]]; then
|
||||
# Check working tree is clean
|
||||
if ! git diff --quiet || ! git diff --cached --quiet; 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" "$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" "$README_FILE" "$ZH_CN_README_FILE" "$SELECTIVE_INSTALL_ARCHITECTURE_DOC"; do
|
||||
for FILE in "$ROOT_PACKAGE_JSON" "$PLUGIN_JSON" "$MARKETPLACE_JSON" "$OPENCODE_PACKAGE_JSON"; do
|
||||
if [[ ! -f "$FILE" ]]; then
|
||||
echo "Error: $FILE not found"
|
||||
exit 1
|
||||
@@ -70,6 +58,13 @@ 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"
|
||||
@@ -80,170 +75,14 @@ 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 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_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" "$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" "$README_FILE" "$ZH_CN_README_FILE" "$SELECTIVE_INSTALL_ARCHITECTURE_DOC"
|
||||
git add "$ROOT_PACKAGE_JSON" "$PLUGIN_JSON" "$MARKETPLACE_JSON" "$OPENCODE_PACKAGE_JSON"
|
||||
git commit -m "chore: bump plugin version to $VERSION"
|
||||
git tag "v$VERSION"
|
||||
git push origin main "v$VERSION"
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
#!/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();
|
||||
@@ -1,132 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
@@ -131,27 +131,6 @@ function runTests() {
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
// 6. Prefers ECC_SESSION_ID for ECC2 session correlation
|
||||
(test('prefers ECC_SESSION_ID over CLAUDE_SESSION_ID when both are present', () => {
|
||||
const tmpHome = makeTempDir();
|
||||
const input = {
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
usage: { input_tokens: 120, output_tokens: 30 },
|
||||
};
|
||||
const result = runScript(input, {
|
||||
...withTempHome(tmpHome),
|
||||
ECC_SESSION_ID: 'ecc-session-1234',
|
||||
CLAUDE_SESSION_ID: 'claude-session-9999',
|
||||
});
|
||||
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
|
||||
|
||||
const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'costs.jsonl');
|
||||
const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim());
|
||||
assert.strictEqual(row.session_id, 'ecc-session-1234', 'Expected ECC_SESSION_ID to win');
|
||||
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
@@ -1,412 +0,0 @@
|
||||
/**
|
||||
* Tests for session-activity-tracker.js hook.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const script = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'scripts',
|
||||
'hooks',
|
||||
'session-activity-tracker.js'
|
||||
);
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function makeTempDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'session-activity-tracker-test-'));
|
||||
}
|
||||
|
||||
function withTempHome(homeDir) {
|
||||
return {
|
||||
HOME: homeDir,
|
||||
USERPROFILE: homeDir,
|
||||
};
|
||||
}
|
||||
|
||||
function runScript(input, envOverrides = {}, options = {}) {
|
||||
const inputStr = typeof input === 'string' ? input : JSON.stringify(input);
|
||||
const result = spawnSync('node', [script], {
|
||||
encoding: 'utf8',
|
||||
input: inputStr,
|
||||
timeout: 10000,
|
||||
env: { ...process.env, ...envOverrides },
|
||||
cwd: options.cwd,
|
||||
});
|
||||
return { code: result.status || 0, stdout: result.stdout || '', stderr: result.stderr || '' };
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing session-activity-tracker.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
(test('passes through input on stdout', () => {
|
||||
const input = {
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: 'README.md' },
|
||||
tool_output: { output: 'ok' },
|
||||
};
|
||||
const inputStr = JSON.stringify(input);
|
||||
const result = runScript(input, {
|
||||
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
|
||||
ECC_SESSION_ID: 'sess-123',
|
||||
});
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.strictEqual(result.stdout, inputStr);
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
(test('creates tool activity metrics rows with file paths', () => {
|
||||
const tmpHome = makeTempDir();
|
||||
const input = {
|
||||
tool_name: 'Write',
|
||||
tool_input: {
|
||||
file_path: 'src/app.rs',
|
||||
},
|
||||
tool_output: { output: 'wrote src/app.rs' },
|
||||
};
|
||||
const result = runScript(input, {
|
||||
...withTempHome(tmpHome),
|
||||
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
|
||||
ECC_SESSION_ID: 'ecc-session-1234',
|
||||
});
|
||||
assert.strictEqual(result.code, 0);
|
||||
|
||||
const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'tool-usage.jsonl');
|
||||
assert.ok(fs.existsSync(metricsFile), `Expected metrics file at ${metricsFile}`);
|
||||
|
||||
const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim());
|
||||
assert.strictEqual(row.session_id, 'ecc-session-1234');
|
||||
assert.strictEqual(row.tool_name, 'Write');
|
||||
assert.strictEqual(row.input_params_json, '{"file_path":"src/app.rs"}');
|
||||
assert.deepStrictEqual(row.file_paths, ['src/app.rs']);
|
||||
assert.deepStrictEqual(row.file_events, [{ path: 'src/app.rs', action: 'create' }]);
|
||||
assert.ok(row.id, 'Expected stable event id');
|
||||
assert.ok(row.timestamp, 'Expected timestamp');
|
||||
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
(test('captures typed move file events from source/destination inputs', () => {
|
||||
const tmpHome = makeTempDir();
|
||||
const input = {
|
||||
tool_name: 'Move',
|
||||
tool_input: {
|
||||
source_path: 'src/old.rs',
|
||||
destination_path: 'src/new.rs',
|
||||
},
|
||||
tool_output: { output: 'moved file' },
|
||||
};
|
||||
const result = runScript(input, {
|
||||
...withTempHome(tmpHome),
|
||||
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
|
||||
ECC_SESSION_ID: 'ecc-session-5678',
|
||||
});
|
||||
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_paths, ['src/old.rs', 'src/new.rs']);
|
||||
assert.deepStrictEqual(row.file_events, [
|
||||
{ path: 'src/old.rs', action: 'move' },
|
||||
{ path: 'src/new.rs', action: 'move' },
|
||||
]);
|
||||
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
(test('captures replacement diff previews for edit tool input', () => {
|
||||
const tmpHome = makeTempDir();
|
||||
const input = {
|
||||
tool_name: 'Edit',
|
||||
tool_input: {
|
||||
file_path: 'src/config.ts',
|
||||
old_string: 'API_URL=http://localhost:3000',
|
||||
new_string: 'API_URL=https://api.example.com',
|
||||
},
|
||||
tool_output: { output: 'updated config' },
|
||||
};
|
||||
const result = runScript(input, {
|
||||
...withTempHome(tmpHome),
|
||||
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
|
||||
ECC_SESSION_ID: 'ecc-session-edit',
|
||||
});
|
||||
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/config.ts',
|
||||
action: 'modify',
|
||||
diff_preview: 'API_URL=http://localhost:3000 -> API_URL=https://api.example.com',
|
||||
patch_preview: '@@\n- API_URL=http://localhost:3000\n+ API_URL=https://api.example.com',
|
||||
},
|
||||
]);
|
||||
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
(test('captures MultiEdit nested edits with typed diff previews', () => {
|
||||
const tmpHome = makeTempDir();
|
||||
const input = {
|
||||
tool_name: 'MultiEdit',
|
||||
tool_input: {
|
||||
edits: [
|
||||
{
|
||||
file_path: 'src/a.ts',
|
||||
old_string: 'const a = 1;',
|
||||
new_string: 'const a = 2;',
|
||||
},
|
||||
{
|
||||
file_path: 'src/b.ts',
|
||||
old_string: 'old name',
|
||||
new_string: 'new name',
|
||||
},
|
||||
],
|
||||
},
|
||||
tool_output: { output: 'updated two files' },
|
||||
};
|
||||
const result = runScript(input, {
|
||||
...withTempHome(tmpHome),
|
||||
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
|
||||
ECC_SESSION_ID: 'ecc-session-multiedit',
|
||||
});
|
||||
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_paths, ['src/a.ts', 'src/b.ts']);
|
||||
assert.deepStrictEqual(row.file_events, [
|
||||
{
|
||||
path: 'src/a.ts',
|
||||
action: 'modify',
|
||||
diff_preview: 'const a = 1; -> const a = 2;',
|
||||
patch_preview: '@@\n- const a = 1;\n+ const a = 2;',
|
||||
},
|
||||
{
|
||||
path: 'src/b.ts',
|
||||
action: 'modify',
|
||||
diff_preview: 'old name -> new name',
|
||||
patch_preview: '@@\n- old name\n+ new name',
|
||||
},
|
||||
]);
|
||||
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
(test('reclassifies tracked Write activity as modify using git diff context', () => {
|
||||
const tmpHome = makeTempDir();
|
||||
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-activity-tracker-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');
|
||||
fs.mkdirSync(srcDir, { 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-write-modify',
|
||||
}, {
|
||||
cwd: repoDir,
|
||||
});
|
||||
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('captures tracked Delete activity using git diff context', () => {
|
||||
const tmpHome = makeTempDir();
|
||||
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-activity-tracker-delete-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');
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
const trackedFile = path.join(srcDir, 'obsolete.ts');
|
||||
fs.writeFileSync(trackedFile, 'export const obsolete = true;\n', 'utf8');
|
||||
spawnSync('git', ['add', 'src/obsolete.ts'], { cwd: repoDir, encoding: 'utf8' });
|
||||
spawnSync('git', ['commit', '-m', 'init'], { cwd: repoDir, encoding: 'utf8' });
|
||||
|
||||
fs.rmSync(trackedFile, { force: true });
|
||||
|
||||
const input = {
|
||||
tool_name: 'Delete',
|
||||
tool_input: {
|
||||
file_path: 'src/obsolete.ts',
|
||||
},
|
||||
tool_output: { output: 'deleted src/obsolete.ts' },
|
||||
};
|
||||
const result = runScript(input, {
|
||||
...withTempHome(tmpHome),
|
||||
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
|
||||
ECC_SESSION_ID: 'ecc-session-delete',
|
||||
}, {
|
||||
cwd: repoDir,
|
||||
});
|
||||
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/obsolete.ts',
|
||||
action: 'delete',
|
||||
diff_preview: 'export const obsolete = true; ->',
|
||||
patch_preview: '@@ -1 +0,0 @@\n-export const obsolete = true;',
|
||||
},
|
||||
]);
|
||||
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
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 = {
|
||||
tool_name: 'Bash',
|
||||
tool_input: {
|
||||
command: 'curl --token abc123 -H "Authorization: Bearer topsecret" https://example.com',
|
||||
},
|
||||
tool_output: { output: 'done' },
|
||||
};
|
||||
const result = runScript(input, {
|
||||
...withTempHome(tmpHome),
|
||||
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
|
||||
ECC_SESSION_ID: 'ecc-session-1',
|
||||
CLAUDE_SESSION_ID: 'claude-session-2',
|
||||
});
|
||||
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.strictEqual(row.session_id, 'ecc-session-1');
|
||||
assert.ok(row.input_summary.includes('<REDACTED>'));
|
||||
assert.ok(!row.input_summary.includes('abc123'));
|
||||
assert.ok(!row.input_summary.includes('topsecret'));
|
||||
assert.ok(row.input_params_json.includes('<REDACTED>'));
|
||||
assert.ok(!row.input_params_json.includes('abc123'));
|
||||
assert.ok(!row.input_params_json.includes('topsecret'));
|
||||
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
(test('handles invalid JSON gracefully', () => {
|
||||
const tmpHome = makeTempDir();
|
||||
const invalidInput = 'not valid json {{{';
|
||||
const result = runScript(invalidInput, {
|
||||
...withTempHome(tmpHome),
|
||||
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
|
||||
ECC_SESSION_ID: 'sess-123',
|
||||
});
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.strictEqual(result.stdout, invalidInput);
|
||||
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -1,48 +0,0 @@
|
||||
'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);
|
||||
@@ -6,9 +6,6 @@ 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,
|
||||
@@ -69,7 +66,7 @@ function runTests() {
|
||||
},
|
||||
],
|
||||
source: {
|
||||
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||
repoVersion: '1.10.0',
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: 1,
|
||||
},
|
||||
@@ -103,7 +100,7 @@ function runTests() {
|
||||
},
|
||||
operations: [],
|
||||
source: {
|
||||
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||
repoVersion: '1.10.0',
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: 1,
|
||||
},
|
||||
@@ -157,7 +154,7 @@ function runTests() {
|
||||
},
|
||||
operations: [operation],
|
||||
source: {
|
||||
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||
repoVersion: '1.10.0',
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: 1,
|
||||
},
|
||||
@@ -211,7 +208,7 @@ function runTests() {
|
||||
skippedModules: [],
|
||||
},
|
||||
source: {
|
||||
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||
repoVersion: '1.10.0',
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: 1,
|
||||
},
|
||||
|
||||
@@ -13,9 +13,6 @@ 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');
|
||||
|
||||
@@ -184,7 +181,7 @@ function runTests() {
|
||||
const homeDir = createTempDir();
|
||||
try {
|
||||
const expected = setupLegacyPluginInstall(homeDir, ['marketplace', 'ecc']);
|
||||
setupPluginCache(homeDir, 'ecc', 'affaan-m', CURRENT_PACKAGE_VERSION);
|
||||
setupPluginCache(homeDir, 'ecc', 'affaan-m', '1.10.0');
|
||||
const result = resolveEccRoot({ envRoot: '', homeDir });
|
||||
assert.strictEqual(result, expected);
|
||||
} finally {
|
||||
@@ -196,7 +193,7 @@ function runTests() {
|
||||
if (test('discovers plugin root from cache directory', () => {
|
||||
const homeDir = createTempDir();
|
||||
try {
|
||||
const expected = setupPluginCache(homeDir, 'ecc', 'affaan-m', CURRENT_PACKAGE_VERSION);
|
||||
const expected = setupPluginCache(homeDir, 'ecc', 'affaan-m', '1.10.0');
|
||||
const result = resolveEccRoot({ envRoot: '', homeDir });
|
||||
assert.strictEqual(result, expected);
|
||||
} finally {
|
||||
@@ -208,7 +205,7 @@ function runTests() {
|
||||
const homeDir = createTempDir();
|
||||
try {
|
||||
const claudeDir = setupStandardInstall(homeDir);
|
||||
setupPluginCache(homeDir, 'ecc', 'affaan-m', CURRENT_PACKAGE_VERSION);
|
||||
setupPluginCache(homeDir, 'ecc', 'affaan-m', '1.10.0');
|
||||
const result = resolveEccRoot({ envRoot: '', homeDir });
|
||||
assert.strictEqual(result, claudeDir,
|
||||
'Standard install should take precedence over plugin cache');
|
||||
@@ -221,7 +218,7 @@ function runTests() {
|
||||
const homeDir = createTempDir();
|
||||
try {
|
||||
setupPluginCache(homeDir, 'everything-claude-code', 'legacy-org', '1.7.0');
|
||||
const expected = setupPluginCache(homeDir, 'ecc', 'affaan-m', CURRENT_PACKAGE_VERSION);
|
||||
const expected = setupPluginCache(homeDir, 'ecc', 'affaan-m', '1.10.0');
|
||||
const result = resolveEccRoot({ envRoot: '', homeDir });
|
||||
// Should find one of them (either is valid)
|
||||
assert.ok(
|
||||
@@ -314,7 +311,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', CURRENT_PACKAGE_VERSION);
|
||||
const expected = setupPluginCache(homeDir, 'ecc', 'affaan-m', '1.10.0');
|
||||
const { execFileSync } = require('child_process');
|
||||
const result = execFileSync('node', [
|
||||
'-e', `console.log(${INLINE_RESOLVE})`,
|
||||
|
||||
@@ -20,20 +20,6 @@ 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');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -78,86 +64,6 @@ 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('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');
|
||||
|
||||
@@ -174,12 +80,8 @@ test('claude plugin.json has version field', () => {
|
||||
assert.ok(claudePlugin.version, 'Expected version field');
|
||||
});
|
||||
|
||||
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 uses short plugin slug', () => {
|
||||
assert.strictEqual(claudePlugin.name, 'ecc');
|
||||
});
|
||||
|
||||
test('claude plugin.json agents is an array', () => {
|
||||
@@ -248,14 +150,10 @@ test('claude marketplace.json keeps only Claude-supported top-level keys', () =>
|
||||
}
|
||||
});
|
||||
|
||||
test('claude marketplace.json has plugins array with the published plugin entry', () => {
|
||||
test('claude marketplace.json has plugins array with a short ecc plugin entry', () => {
|
||||
assert.ok(Array.isArray(claudeMarketplace.plugins) && claudeMarketplace.plugins.length > 0, 'Expected plugins array');
|
||||
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);
|
||||
assert.strictEqual(claudeMarketplace.name, 'ecc');
|
||||
assert.strictEqual(claudeMarketplace.plugins[0].name, 'ecc');
|
||||
});
|
||||
|
||||
// ── Codex plugin manifest ─────────────────────────────────────────────────────
|
||||
@@ -285,10 +183,6 @@ 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,
|
||||
@@ -374,7 +268,6 @@ 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');
|
||||
@@ -391,7 +284,6 @@ 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`);
|
||||
@@ -402,10 +294,6 @@ 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') {
|
||||
@@ -429,30 +317,6 @@ 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}`);
|
||||
|
||||
@@ -1,395 +0,0 @@
|
||||
/**
|
||||
* Tests for scripts/auto-update.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
parseArgs,
|
||||
deriveRepoRootFromState,
|
||||
buildInstallApplyArgs,
|
||||
determineInstallCwd,
|
||||
runAutoUpdate,
|
||||
} = require('../../scripts/auto-update');
|
||||
const {
|
||||
createInstallState,
|
||||
} = require('../../scripts/lib/install-state');
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function makeRecord({ repoRoot, homeDir, projectRoot, adapter, request, resolution, operations }) {
|
||||
const targetRoot = adapter.kind === 'project'
|
||||
? path.join(projectRoot, `.${adapter.target}`)
|
||||
: path.join(homeDir, '.claude');
|
||||
const installStatePath = adapter.kind === 'project'
|
||||
? path.join(targetRoot, 'ecc-install-state.json')
|
||||
: path.join(targetRoot, 'ecc', 'install-state.json');
|
||||
|
||||
const state = createInstallState({
|
||||
adapter,
|
||||
targetRoot,
|
||||
installStatePath,
|
||||
request,
|
||||
resolution,
|
||||
operations,
|
||||
source: {
|
||||
repoVersion: '1.10.0',
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: 1,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
adapter,
|
||||
targetRoot,
|
||||
installStatePath,
|
||||
exists: true,
|
||||
state,
|
||||
error: null,
|
||||
repoRoot,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureFakeRepo(repoRoot) {
|
||||
fs.mkdirSync(path.join(repoRoot, 'scripts'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(repoRoot, 'package.json'),
|
||||
JSON.stringify({ name: 'everything-claude-code', version: '1.10.0' }, null, 2)
|
||||
);
|
||||
fs.writeFileSync(path.join(repoRoot, 'scripts', 'install-apply.js'), '#!/usr/bin/env node\n');
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing auto-update.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('parseArgs reads repo-root, target, dry-run, and json flags', () => {
|
||||
const parsed = parseArgs([
|
||||
'node',
|
||||
'scripts/auto-update.js',
|
||||
'--target',
|
||||
'cursor',
|
||||
'--repo-root',
|
||||
'/tmp/ecc',
|
||||
'--dry-run',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
assert.deepStrictEqual(parsed.targets, ['cursor']);
|
||||
assert.strictEqual(parsed.repoRoot, '/tmp/ecc');
|
||||
assert.strictEqual(parsed.dryRun, true);
|
||||
assert.strictEqual(parsed.json, true);
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (test('parseArgs rejects unknown arguments', () => {
|
||||
assert.throws(
|
||||
() => parseArgs(['node', 'scripts/auto-update.js', '--bogus']),
|
||||
/Unknown argument: --bogus/
|
||||
);
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (test('deriveRepoRootFromState uses sourcePath and sourceRelativePath', () => {
|
||||
const state = {
|
||||
operations: [
|
||||
{
|
||||
sourcePath: path.join('/tmp', 'ecc', 'scripts', 'setup-package-manager.js'),
|
||||
sourceRelativePath: path.join('scripts', 'setup-package-manager.js'),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
assert.strictEqual(
|
||||
deriveRepoRootFromState(state),
|
||||
path.resolve(path.join('/tmp', 'ecc'))
|
||||
);
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (test('deriveRepoRootFromState fails when source metadata is unavailable', () => {
|
||||
assert.throws(
|
||||
() => deriveRepoRootFromState({ operations: [{ destinationPath: '/tmp/file' }] }),
|
||||
/Unable to infer ECC repo root/
|
||||
);
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (test('buildInstallApplyArgs reconstructs legacy installs', () => {
|
||||
const record = {
|
||||
adapter: { target: 'claude', kind: 'home' },
|
||||
state: {
|
||||
target: { target: 'claude' },
|
||||
request: {
|
||||
profile: null,
|
||||
modules: [],
|
||||
includeComponents: [],
|
||||
excludeComponents: [],
|
||||
legacyLanguages: ['typescript', 'python'],
|
||||
legacyMode: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepStrictEqual(buildInstallApplyArgs(record), [
|
||||
'--target', 'claude',
|
||||
'typescript',
|
||||
'python',
|
||||
]);
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (test('buildInstallApplyArgs reconstructs manifest installs', () => {
|
||||
const record = {
|
||||
adapter: { target: 'cursor', kind: 'project' },
|
||||
state: {
|
||||
target: { target: 'cursor' },
|
||||
request: {
|
||||
profile: 'developer',
|
||||
modules: ['platform-configs'],
|
||||
includeComponents: ['component:alpha'],
|
||||
excludeComponents: ['component:beta'],
|
||||
legacyLanguages: [],
|
||||
legacyMode: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepStrictEqual(buildInstallApplyArgs(record), [
|
||||
'--target', 'cursor',
|
||||
'--profile', 'developer',
|
||||
'--modules', 'platform-configs',
|
||||
'--with', 'component:alpha',
|
||||
'--without', 'component:beta',
|
||||
]);
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (test('determineInstallCwd uses the project root for project installs', () => {
|
||||
const record = {
|
||||
adapter: { kind: 'project' },
|
||||
state: {
|
||||
target: {
|
||||
root: path.join('/tmp', 'project', '.cursor'),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
assert.strictEqual(determineInstallCwd(record, '/tmp/ecc'), path.join('/tmp', 'project'));
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (test('runAutoUpdate reports when no install-state files are present', () => {
|
||||
const result = runAutoUpdate(
|
||||
{
|
||||
homeDir: '/tmp/home',
|
||||
projectRoot: '/tmp/project',
|
||||
dryRun: true,
|
||||
},
|
||||
{
|
||||
discoverInstalledStates: () => [],
|
||||
}
|
||||
);
|
||||
|
||||
assert.strictEqual(result.results.length, 0);
|
||||
assert.strictEqual(result.summary.checkedCount, 0);
|
||||
assert.strictEqual(result.summary.errorCount, 0);
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (test('runAutoUpdate rejects mixed inferred repo roots', () => {
|
||||
const homeDir = createTempDir('auto-update-home-');
|
||||
const projectRoot = createTempDir('auto-update-project-');
|
||||
const repoOne = createTempDir('auto-update-repo-');
|
||||
const repoTwo = createTempDir('auto-update-repo-');
|
||||
|
||||
try {
|
||||
ensureFakeRepo(repoOne);
|
||||
ensureFakeRepo(repoTwo);
|
||||
|
||||
const records = [
|
||||
makeRecord({
|
||||
repoRoot: repoOne,
|
||||
homeDir,
|
||||
projectRoot,
|
||||
adapter: { id: 'claude-home', target: 'claude', kind: 'home' },
|
||||
request: {
|
||||
profile: null,
|
||||
modules: [],
|
||||
includeComponents: [],
|
||||
excludeComponents: [],
|
||||
legacyLanguages: ['typescript'],
|
||||
legacyMode: true,
|
||||
},
|
||||
resolution: { selectedModules: ['legacy-claude-rules'], skippedModules: [] },
|
||||
operations: [
|
||||
{
|
||||
kind: 'copy-file',
|
||||
moduleId: 'legacy-claude-rules',
|
||||
sourcePath: path.join(repoOne, 'rules', 'common', 'coding-style.md'),
|
||||
sourceRelativePath: path.join('rules', 'common', 'coding-style.md'),
|
||||
destinationPath: path.join(homeDir, '.claude', 'rules', 'common', 'coding-style.md'),
|
||||
strategy: 'preserve-relative-path',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
makeRecord({
|
||||
repoRoot: repoTwo,
|
||||
homeDir,
|
||||
projectRoot,
|
||||
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
|
||||
request: {
|
||||
profile: 'core',
|
||||
modules: [],
|
||||
includeComponents: [],
|
||||
excludeComponents: [],
|
||||
legacyLanguages: [],
|
||||
legacyMode: false,
|
||||
},
|
||||
resolution: { selectedModules: ['rules-core'], skippedModules: [] },
|
||||
operations: [
|
||||
{
|
||||
kind: 'copy-file',
|
||||
moduleId: 'rules-core',
|
||||
sourcePath: path.join(repoTwo, '.cursor', 'mcp.json'),
|
||||
sourceRelativePath: path.join('.cursor', 'mcp.json'),
|
||||
destinationPath: path.join(projectRoot, '.cursor', 'mcp.json'),
|
||||
strategy: 'sync-root-children',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
assert.throws(
|
||||
() => runAutoUpdate(
|
||||
{
|
||||
homeDir,
|
||||
projectRoot,
|
||||
dryRun: true,
|
||||
},
|
||||
{
|
||||
discoverInstalledStates: () => records,
|
||||
}
|
||||
),
|
||||
/Multiple ECC repo roots detected/
|
||||
);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
cleanup(repoOne);
|
||||
cleanup(repoTwo);
|
||||
}
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (test('runAutoUpdate fetches, pulls, and reinstalls using reconstructed args', () => {
|
||||
const homeDir = createTempDir('auto-update-home-');
|
||||
const projectRoot = createTempDir('auto-update-project-');
|
||||
const repoRoot = createTempDir('auto-update-repo-');
|
||||
|
||||
try {
|
||||
ensureFakeRepo(repoRoot);
|
||||
|
||||
const records = [
|
||||
makeRecord({
|
||||
repoRoot,
|
||||
homeDir,
|
||||
projectRoot,
|
||||
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
|
||||
request: {
|
||||
profile: 'developer',
|
||||
modules: [],
|
||||
includeComponents: ['component:alpha'],
|
||||
excludeComponents: ['component:beta'],
|
||||
legacyLanguages: [],
|
||||
legacyMode: false,
|
||||
},
|
||||
resolution: { selectedModules: ['rules-core'], skippedModules: [] },
|
||||
operations: [
|
||||
{
|
||||
kind: 'copy-file',
|
||||
moduleId: 'platform-configs',
|
||||
sourcePath: path.join(repoRoot, '.cursor', 'mcp.json'),
|
||||
sourceRelativePath: path.join('.cursor', 'mcp.json'),
|
||||
destinationPath: path.join(projectRoot, '.cursor', 'mcp.json'),
|
||||
strategy: 'sync-root-children',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const commands = [];
|
||||
const result = runAutoUpdate(
|
||||
{
|
||||
homeDir,
|
||||
projectRoot,
|
||||
dryRun: false,
|
||||
},
|
||||
{
|
||||
discoverInstalledStates: () => records,
|
||||
runExternalCommand: (command, args, options) => {
|
||||
commands.push({ command, args, options });
|
||||
if (command === process.execPath) {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
dryRun: false,
|
||||
result: {
|
||||
installStatePath: path.join(projectRoot, '.cursor', 'ecc-install-state.json'),
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
|
||||
return { stdout: '', stderr: '' };
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
assert.strictEqual(result.summary.checkedCount, 1);
|
||||
assert.strictEqual(result.summary.updatedCount, 1);
|
||||
assert.deepStrictEqual(commands.map(entry => [entry.command, entry.args[0]]), [
|
||||
['git', 'fetch'],
|
||||
['git', 'pull'],
|
||||
[process.execPath, path.join(repoRoot, 'scripts', 'install-apply.js')],
|
||||
]);
|
||||
assert.deepStrictEqual(commands[2].args.slice(1), [
|
||||
'--target', 'cursor',
|
||||
'--profile', 'developer',
|
||||
'--with', 'component:alpha',
|
||||
'--without', 'component:beta',
|
||||
'--json',
|
||||
]);
|
||||
assert.strictEqual(commands[2].options.cwd, projectRoot);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
cleanup(repoRoot);
|
||||
}
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -49,9 +49,8 @@ function main() {
|
||||
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)
|
||||
assert.strictEqual(result.status, 0, result.stderr)
|
||||
|
||||
const packOutput = JSON.parse(result.stdout)
|
||||
const packagedPaths = new Set(packOutput[0]?.files?.map((file) => file.path) ?? [])
|
||||
@@ -68,42 +67,6 @@ 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"
|
||||
)
|
||||
}],
|
||||
]
|
||||
|
||||
|
||||
@@ -68,7 +68,6 @@ function main() {
|
||||
assert.match(result.stdout, /catalog/);
|
||||
assert.match(result.stdout, /list-installed/);
|
||||
assert.match(result.stdout, /doctor/);
|
||||
assert.match(result.stdout, /auto-update/);
|
||||
}],
|
||||
['delegates explicit install command', () => {
|
||||
const result = runCli(['install', '--dry-run', '--json', 'typescript']);
|
||||
@@ -113,17 +112,6 @@ function main() {
|
||||
const payload = parseJson(result.stdout);
|
||||
assert.deepStrictEqual(payload.records, []);
|
||||
}],
|
||||
['delegates auto-update command', () => {
|
||||
const homeDir = createTempDir('ecc-cli-home-');
|
||||
const projectRoot = createTempDir('ecc-cli-project-');
|
||||
const result = runCli(['auto-update', '--dry-run', '--json'], {
|
||||
cwd: projectRoot,
|
||||
env: { HOME: homeDir },
|
||||
});
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const payload = parseJson(result.stdout);
|
||||
assert.deepStrictEqual(payload.results, []);
|
||||
}],
|
||||
['delegates session-inspect command', () => {
|
||||
const homeDir = createTempDir('ecc-cli-home-');
|
||||
const sessionsDir = path.join(homeDir, '.claude', 'sessions');
|
||||
@@ -147,11 +135,6 @@ function main() {
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /Usage: node scripts\/repair\.js/);
|
||||
}],
|
||||
['supports help for the auto-update subcommand', () => {
|
||||
const result = runCli(['help', 'auto-update']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /Usage: node scripts\/auto-update\.js/);
|
||||
}],
|
||||
['supports help for the catalog subcommand', () => {
|
||||
const result = runCli(['help', 'catalog']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
|
||||
@@ -23,6 +23,8 @@ 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 = {
|
||||
@@ -329,7 +331,7 @@ function runTests() {
|
||||
assert.ok(result.stderr.includes('Unknown install module: ghost-module'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('installs claude hooks without generating settings.json', () => {
|
||||
if (test('merges hooks into settings.json for claude target install', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
@@ -339,7 +341,15 @@ function runTests() {
|
||||
|
||||
const claudeRoot = path.join(homeDir, '.claude');
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')), 'hooks.json should be copied');
|
||||
assert.ok(!fs.existsSync(path.join(claudeRoot, 'settings.json')), 'settings.json should not be created just to install managed hooks');
|
||||
|
||||
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');
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
@@ -355,11 +365,23 @@ 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(
|
||||
@@ -376,7 +398,7 @@ function runTests() {
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('preserves existing settings.json without mutating it during claude install', () => {
|
||||
if (test('preserves existing settings fields and hook entries when merging hooks', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
@@ -401,15 +423,17 @@ 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' }] }],
|
||||
'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'
|
||||
'user-defined hook event types should be preserved'
|
||||
);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
@@ -491,7 +515,7 @@ function runTests() {
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('reinstall does not create settings.json when only managed hooks are installed', () => {
|
||||
if (test('reinstall does not duplicate managed hook entries', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
@@ -499,43 +523,69 @@ function runTests() {
|
||||
const firstInstall = run(['--profile', 'core'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(firstInstall.code, 0, firstInstall.stderr);
|
||||
|
||||
const secondInstall = run(['--profile', 'core'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(secondInstall.code, 0, secondInstall.stderr);
|
||||
|
||||
assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'settings.json')));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
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 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 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.deepStrictEqual(afterSecondInstall, legacySettings);
|
||||
assert.strictEqual(
|
||||
afterSecondInstall.hooks.PreToolUse.length,
|
||||
preToolUseLength,
|
||||
'managed hook entries should not duplicate on reinstall'
|
||||
);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('ignores malformed existing settings.json during claude install', () => {
|
||||
if (test('reinstall deduplicates legacy hooks without ids against new managed ids', () => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('fails when existing settings.json is malformed', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
@@ -546,17 +596,18 @@ function runTests() {
|
||||
fs.writeFileSync(settingsPath, '{ invalid json\n');
|
||||
|
||||
const result = run(['--profile', 'core'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stderr.includes('Failed to parse existing settings at'));
|
||||
assert.strictEqual(fs.readFileSync(settingsPath, 'utf8'), '{ invalid json\n');
|
||||
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');
|
||||
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');
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('ignores non-object existing settings.json during claude install', () => {
|
||||
if (test('fails when existing settings.json root is not an object', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
@@ -567,10 +618,12 @@ function runTests() {
|
||||
fs.writeFileSync(settingsPath, '[]\n');
|
||||
|
||||
const result = run(['--profile', 'core'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
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(fs.readFileSync(settingsPath, 'utf8'), '[]\n');
|
||||
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');
|
||||
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');
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
@@ -578,64 +631,26 @@ function runTests() {
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('fails when source hooks.json root is not an object before copying files', () => {
|
||||
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');
|
||||
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');
|
||||
|
||||
try {
|
||||
fs.writeFileSync(sourceHooksPath, '[]\n');
|
||||
|
||||
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 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.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');
|
||||
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');
|
||||
} finally {
|
||||
cleanup(tempDir);
|
||||
fs.writeFileSync(sourceHooksPath, originalHooks);
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
Reference in New Issue
Block a user