mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-13 19:51:24 +08:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ea51ded9a | |||
| 03c4e51a16 | |||
| 1380ce7df5 | |||
| 6fd20ffc72 | |||
| 7fa1e5b6db | |||
| f442bac8c9 | |||
| 12e1bc424d | |||
| e674a7dbd7 | |||
| 1abc3fb381 | |||
| 27508842b1 | |||
| 8a57679222 | |||
| 7b964402ee | |||
| f8a0c4f884 | |||
| 754bdbf440 | |||
| f01929c31a | |||
| e196f8a4cb | |||
| 600072ebd8 | |||
| 2bb88cff47 | |||
| 105b524c8f | |||
| 61a30a1f15 | |||
| c013479019 | |||
| baba4ec1ab | |||
| 01b171947c | |||
| 841beea45c | |||
| 61992f7f5e | |||
| 2715315438 | |||
| 7627926216 | |||
| 20154ddb22 | |||
| bb40978e31 | |||
| 7c5452f4fa | |||
| cfe770a735 | |||
| 4c8499d509 | |||
| 85dfb5e5fc | |||
| 7b03a60503 | |||
| fbd441b448 |
@@ -136,6 +136,7 @@ The test `plugin.json does NOT have explicit hooks declaration` in `tests/hooks/
|
||||
|
||||
ECC keeps `.mcp.json` at the repository root for Codex plugin installs and manual MCP setup.
|
||||
Claude Code also auto-discovers plugin-root `.mcp.json` files by convention, which would bundle the same MCP servers into Claude plugin installs.
|
||||
The Claude plugin slug is intentionally short (`ecc`), but this opt-out is still required because legacy installs and strict provider gateways have failed on generated names from longer plugin identifiers.
|
||||
|
||||
Keep this field in `.claude-plugin/plugin.json`:
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "everything-claude-code",
|
||||
"name": "ecc",
|
||||
"owner": {
|
||||
"name": "Affaan Mustafa",
|
||||
"email": "me@affaanmustafa.com"
|
||||
@@ -9,9 +9,9 @@
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "everything-claude-code",
|
||||
"name": "ecc",
|
||||
"source": "./",
|
||||
"description": "The most comprehensive Claude Code plugin — 48 agents, 182 skills, 68 legacy command shims, selective install profiles, and production-ready hooks for TDD, security scanning, code review, and continuous learning",
|
||||
"description": "The most comprehensive Claude Code plugin — 50 agents, 185 skills, 68 legacy command shims, selective install profiles, and production-ready hooks for TDD, security scanning, code review, and continuous learning",
|
||||
"version": "2.0.0-rc.1",
|
||||
"author": {
|
||||
"name": "Affaan Mustafa",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "everything-claude-code",
|
||||
"name": "ecc",
|
||||
"version": "2.0.0-rc.1",
|
||||
"description": "Battle-tested Claude Code plugin for engineering teams — 48 agents, 182 skills, 68 legacy command shims, production-ready hooks, and selective install workflows evolved through continuous real-world use",
|
||||
"description": "Battle-tested Claude Code plugin for engineering teams — 50 agents, 185 skills, 68 legacy command shims, production-ready hooks, and selective install workflows evolved through continuous real-world use",
|
||||
"author": {
|
||||
"name": "Affaan Mustafa",
|
||||
"url": "https://x.com/affaanmustafa"
|
||||
|
||||
@@ -12,7 +12,7 @@ This directory contains the **Codex plugin manifest** for Everything Claude Code
|
||||
|
||||
## What This Provides
|
||||
|
||||
- **182 skills** from `./skills/` — reusable Codex workflows for TDD, security,
|
||||
- **185 skills** from `./skills/` — reusable Codex workflows for TDD, security,
|
||||
code review, architecture, and more
|
||||
- **6 MCP servers** — GitHub, Context7, Exa, Memory, Playwright, Sequential Thinking
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ecc",
|
||||
"version": "2.0.0-rc.1",
|
||||
"description": "Battle-tested Codex workflows — 182 shared ECC skills, production-ready MCP configs, and selective-install-aligned conventions for TDD, security scanning, code review, and autonomous development.",
|
||||
"description": "Battle-tested Codex workflows — 185 shared ECC skills, production-ready MCP configs, and selective-install-aligned conventions for TDD, security scanning, code review, and autonomous development.",
|
||||
"author": {
|
||||
"name": "Affaan Mustafa",
|
||||
"email": "me@affaanmustafa.com",
|
||||
@@ -15,7 +15,7 @@
|
||||
"mcpServers": "./.mcp.json",
|
||||
"interface": {
|
||||
"displayName": "Everything Claude Code",
|
||||
"shortDescription": "182 battle-tested ECC skills plus MCP configs for TDD, security, code review, and autonomous development.",
|
||||
"shortDescription": "185 battle-tested ECC skills plus MCP configs for TDD, security, code review, and autonomous development.",
|
||||
"longDescription": "Everything Claude Code (ECC) is a community-maintained collection of Codex-ready skills and MCP configs evolved over 10+ months of intensive daily use. It covers TDD workflows, security scanning, code review, architecture decisions, operator workflows, and more — all in one installable plugin.",
|
||||
"developerName": "Affaan Mustafa",
|
||||
"category": "Productivity",
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
# Package manager setup
|
||||
- name: Setup pnpm
|
||||
if: matrix.pm == 'pnpm' && matrix.node != '18.x'
|
||||
uses: pnpm/action-setup@08c4be7e2e672a47d11bd04269e27e5f3e8529cb # v6.0.0
|
||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 # v6.0.6
|
||||
with:
|
||||
# Keep an explicit pnpm major because this repo's packageManager is Yarn.
|
||||
version: 10
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
|
||||
- name: Cache npm
|
||||
if: matrix.pm == 'npm'
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ${{ steps.npm-cache-dir.outputs.dir }}
|
||||
key: ${{ runner.os }}-node-${{ matrix.node }}-npm-${{ hashFiles('**/package-lock.json') }}
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
|
||||
- name: Cache pnpm
|
||||
if: matrix.pm == 'pnpm'
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache-dir.outputs.dir }}
|
||||
key: ${{ runner.os }}-node-${{ matrix.node }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
@@ -115,7 +115,7 @@ jobs:
|
||||
|
||||
- name: Cache yarn
|
||||
if: matrix.pm == 'yarn'
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ${{ steps.yarn-cache-dir.outputs.dir }}
|
||||
key: ${{ runner.os }}-node-${{ matrix.node }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
@@ -124,7 +124,7 @@ jobs:
|
||||
|
||||
- name: Cache bun
|
||||
if: matrix.pm == 'bun'
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
|
||||
@@ -220,6 +220,10 @@ jobs:
|
||||
run: node scripts/ci/check-unicode-safety.js
|
||||
continue-on-error: false
|
||||
|
||||
- name: Validate no personal paths
|
||||
run: node scripts/ci/validate-no-personal-paths.js
|
||||
continue-on-error: false
|
||||
|
||||
security:
|
||||
name: Security Scan
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
- name: Setup pnpm
|
||||
if: inputs.package-manager == 'pnpm' && inputs.node-version != '18.x'
|
||||
uses: pnpm/action-setup@08c4be7e2e672a47d11bd04269e27e5f3e8529cb # v6.0.0
|
||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 # v6.0.6
|
||||
with:
|
||||
# Keep an explicit pnpm major because this repo's packageManager is Yarn.
|
||||
version: 10
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
|
||||
- name: Cache npm
|
||||
if: inputs.package-manager == 'npm'
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ${{ steps.npm-cache-dir.outputs.dir }}
|
||||
key: ${{ runner.os }}-node-${{ inputs.node-version }}-npm-${{ hashFiles('**/package-lock.json') }}
|
||||
@@ -84,7 +84,7 @@ jobs:
|
||||
|
||||
- name: Cache pnpm
|
||||
if: inputs.package-manager == 'pnpm'
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache-dir.outputs.dir }}
|
||||
key: ${{ runner.os }}-node-${{ inputs.node-version }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
@@ -105,7 +105,7 @@ jobs:
|
||||
|
||||
- name: Cache yarn
|
||||
if: inputs.package-manager == 'yarn'
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ${{ steps.yarn-cache-dir.outputs.dir }}
|
||||
key: ${{ runner.os }}-node-${{ inputs.node-version }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
@@ -114,7 +114,7 @@ jobs:
|
||||
|
||||
- name: Cache bun
|
||||
if: inputs.package-manager == 'bun'
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
|
||||
|
||||
@@ -50,3 +50,6 @@ jobs:
|
||||
|
||||
- name: Check unicode safety
|
||||
run: node scripts/ci/check-unicode-safety.js
|
||||
|
||||
- name: Validate no personal paths
|
||||
run: node scripts/ci/validate-no-personal-paths.js
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Everything Claude Code (ECC) — Agent Instructions
|
||||
|
||||
This is a **production-ready AI coding plugin** providing 48 specialized agents, 182 skills, 68 commands, and automated hook workflows for software development.
|
||||
This is a **production-ready AI coding plugin** providing 50 specialized agents, 185 skills, 68 commands, and automated hook workflows for software development.
|
||||
|
||||
**Version:** 2.0.0-rc.1
|
||||
|
||||
@@ -145,8 +145,8 @@ Troubleshoot failures: check test isolation → verify mocks → fix implementat
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
agents/ — 48 specialized subagents
|
||||
skills/ — 182 workflow skills and domain knowledge
|
||||
agents/ — 50 specialized subagents
|
||||
skills/ — 185 workflow skills and domain knowledge
|
||||
commands/ — 68 slash commands
|
||||
hooks/ — Trigger-based automations
|
||||
rules/ — Always-follow guidelines (common + per-language)
|
||||
|
||||
@@ -167,6 +167,8 @@ Short version:
|
||||
- [ ] Tested with Claude Code
|
||||
- [ ] Links to related skills
|
||||
- [ ] No sensitive data (API keys, tokens, paths)
|
||||
- [ ] Frontmatter declares `name:` matching the directory name
|
||||
- [ ] Frontmatter `description:` is an inline string or folded (`>`) scalar — not a literal block (`|`, `|-`, or `|+`), which preserves internal newlines and breaks flat-table renderers
|
||||
|
||||
### Example Skills
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
**Language:** English | [Português (Brasil)](docs/pt-BR/README.md) | [简体中文](README.zh-CN.md) | [繁體中文](docs/zh-TW/README.md) | [日本語](docs/ja-JP/README.md) | [한국어](docs/ko-KR/README.md) | [Türkçe](docs/tr/README.md)
|
||||
**Language:** English | [Português (Brasil)](docs/pt-BR/README.md) | [简体中文](README.zh-CN.md) | [繁體中文](docs/zh-TW/README.md) | [日本語](docs/ja-JP/README.md) | [한국어](docs/ko-KR/README.md) | [Türkçe](docs/tr/README.md) | [Русский](docs/ru/README.md)
|
||||
|
||||
# Everything Claude Code
|
||||
|
||||
@@ -25,10 +25,10 @@
|
||||
|
||||
<div align="center">
|
||||
|
||||
**Language / 语言 / 語言 / Dil**
|
||||
**Language / 语言 / 語言 / Dil / Язык**
|
||||
|
||||
[**English**](README.md) | [Português (Brasil)](docs/pt-BR/README.md) | [简体中文](README.zh-CN.md) | [繁體中文](docs/zh-TW/README.md) | [日本語](docs/ja-JP/README.md) | [한국어](docs/ko-KR/README.md)
|
||||
| [Türkçe](docs/tr/README.md)
|
||||
| [Türkçe](docs/tr/README.md) | [Русский](docs/ru/README.md)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -89,7 +89,7 @@ This repo is the raw code only. The guides explain everything.
|
||||
### v2.0.0-rc.1 — Surface Refresh, Operator Workflows, and ECC 2.0 Alpha (Apr 2026)
|
||||
|
||||
- **Dashboard GUI** — New Tkinter-based desktop application (`ecc_dashboard.py` or `npm run dashboard`) with dark/light theme toggle, font customization, and project logo in header and taskbar.
|
||||
- **Public surface synced to the live repo** — metadata, catalog counts, plugin manifests, and install-facing docs now match the actual OSS surface: 48 agents, 182 skills, and 68 legacy command shims.
|
||||
- **Public surface synced to the live repo** — metadata, catalog counts, plugin manifests, and install-facing docs now match the actual OSS surface: 48 agents, 185 skills, and 68 legacy command shims.
|
||||
- **Operator and outbound workflow expansion** — `brand-voice`, `social-graph-ranker`, `connections-optimizer`, `customer-billing-ops`, `ecc-tools-cost-audit`, `google-workspace-ops`, `project-flow-ops`, and `workspace-surface-audit` round out the operator lane.
|
||||
- **Media and launch tooling** — `manim-video`, `remotion-video-creation`, and upgraded social publishing surfaces make technical explainers and launch content part of the same system.
|
||||
- **Framework and product surface growth** — `nestjs-patterns`, richer Codex/OpenCode install surfaces, and expanded cross-harness packaging keep the repo usable beyond Claude Code alone.
|
||||
@@ -226,7 +226,7 @@ It returns matching components, related profiles, and preview/install commands.
|
||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
||||
|
||||
# Install plugin
|
||||
/plugin install everything-claude-code@everything-claude-code
|
||||
/plugin install ecc@ecc
|
||||
```
|
||||
|
||||
### Naming + Migration Note
|
||||
@@ -234,12 +234,12 @@ It returns matching components, related profiles, and preview/install commands.
|
||||
ECC now has three public identifiers, and they are not interchangeable:
|
||||
|
||||
- GitHub source repo: `affaan-m/everything-claude-code`
|
||||
- Claude marketplace/plugin identifier: `everything-claude-code@everything-claude-code`
|
||||
- Claude marketplace/plugin identifier: `ecc@ecc`
|
||||
- npm package: `ecc-universal`
|
||||
|
||||
This is intentional. Anthropic marketplace/plugin installs are keyed by a canonical plugin identifier, so ECC standardized on `everything-claude-code@everything-claude-code` to keep the listing name, `/plugin install`, `/plugin list`, and repo docs aligned to one public install surface. Older posts may still show the old short-form nickname; that shorthand is deprecated. Separately, the npm package stayed on `ecc-universal`, so npm installs and marketplace installs intentionally use different names.
|
||||
This is intentional. Anthropic marketplace/plugin installs are keyed by a canonical plugin identifier, so ECC uses `ecc@ecc` to keep tool names and slash-command namespaces short enough for strict Desktop/API validators. Older posts may still show the former long marketplace identifier; treat that as a legacy alias only. Separately, the npm package stayed on `ecc-universal`, so npm installs and marketplace installs intentionally use different names.
|
||||
|
||||
### Step 2: Install Rules (Required)
|
||||
### Step 2: Install Rules Only If You Need Them
|
||||
|
||||
> WARNING: **Important:** Claude Code plugins cannot distribute `rules` automatically.
|
||||
>
|
||||
@@ -341,16 +341,16 @@ If you stacked methods, clean up in this order:
|
||||
# Existing slash-style command names still work while ECC migrates off commands/.
|
||||
|
||||
# Plugin install uses the canonical namespaced form
|
||||
/everything-claude-code:plan "Add user authentication"
|
||||
/ecc:plan "Add user authentication"
|
||||
|
||||
# Manual install keeps the shorter slash form:
|
||||
# /plan "Add user authentication"
|
||||
|
||||
# Check available commands
|
||||
/plugin list everything-claude-code@everything-claude-code
|
||||
/plugin list ecc@ecc
|
||||
```
|
||||
|
||||
**That's it!** You now have access to 48 agents, 182 skills, and 68 legacy command shims.
|
||||
**That's it!** You now have access to 50 agents, 185 skills, and 68 legacy command shims.
|
||||
|
||||
### Dashboard GUI
|
||||
|
||||
@@ -448,7 +448,7 @@ everything-claude-code/
|
||||
| |-- plugin.json # Plugin metadata and component paths
|
||||
| |-- marketplace.json # Marketplace catalog for /plugin marketplace add
|
||||
|
|
||||
|-- agents/ # 36 specialized subagents for delegation
|
||||
|-- agents/ # 50 specialized subagents for delegation
|
||||
| |-- planner.md # Feature implementation planning
|
||||
| |-- architect.md # System design decisions
|
||||
| |-- tdd-guide.md # Test-driven development
|
||||
@@ -767,7 +767,7 @@ The easiest way to use this repo - install as a Claude Code plugin:
|
||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
||||
|
||||
# Install the plugin
|
||||
/plugin install everything-claude-code@everything-claude-code
|
||||
/plugin install ecc@ecc
|
||||
```
|
||||
|
||||
Or add directly to your `~/.claude/settings.json`:
|
||||
@@ -783,7 +783,7 @@ Or add directly to your `~/.claude/settings.json`:
|
||||
}
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"everything-claude-code@everything-claude-code": true
|
||||
"ecc@ecc": true
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -961,8 +961,8 @@ Not sure where to start? Use this quick reference. Skills are the canonical work
|
||||
|
||||
| I want to... | Use this surface | Agent used |
|
||||
|--------------|-----------------|------------|
|
||||
| Plan a new feature | `/everything-claude-code:plan "Add auth"` | planner |
|
||||
| Design system architecture | `/everything-claude-code:plan` + architect agent | architect |
|
||||
| Plan a new feature | `/ecc:plan "Add auth"` | planner |
|
||||
| Design system architecture | `/ecc:plan` + architect agent | architect |
|
||||
| Write code with tests first | `tdd-workflow` skill | tdd-guide |
|
||||
| Review code I just wrote | `/code-review` | code-reviewer |
|
||||
| Fix a failing build | `/build-fix` | build-error-resolver |
|
||||
@@ -981,7 +981,7 @@ Slash forms below are shown where they remain part of the maintained command sur
|
||||
|
||||
**Starting a new feature:**
|
||||
```
|
||||
/everything-claude-code:plan "Add user authentication with OAuth"
|
||||
/ecc:plan "Add user authentication with OAuth"
|
||||
→ planner creates implementation blueprint
|
||||
tdd-workflow skill → tdd-guide enforces write-tests-first
|
||||
/code-review → code-reviewer checks your work
|
||||
@@ -1009,7 +1009,7 @@ e2e-testing skill → e2e-runner: critical user flow
|
||||
<summary><b>How do I check which agents/commands are installed?</b></summary>
|
||||
|
||||
```bash
|
||||
/plugin list everything-claude-code@everything-claude-code
|
||||
/plugin list ecc@ecc
|
||||
```
|
||||
|
||||
This shows all available agents, commands, and skills from the plugin.
|
||||
@@ -1336,9 +1336,9 @@ The configuration is automatically detected from `.opencode/opencode.json`.
|
||||
|
||||
| Feature | Claude Code | OpenCode | Status |
|
||||
|---------|-------------|----------|--------|
|
||||
| Agents | PASS: 48 agents | PASS: 12 agents | **Claude Code leads** |
|
||||
| Agents | PASS: 50 agents | PASS: 12 agents | **Claude Code leads** |
|
||||
| Commands | PASS: 68 commands | PASS: 31 commands | **Claude Code leads** |
|
||||
| Skills | PASS: 182 skills | PASS: 37 skills | **Claude Code leads** |
|
||||
| Skills | PASS: 185 skills | PASS: 37 skills | **Claude Code leads** |
|
||||
| Hooks | PASS: 8 event types | PASS: 11 events | **OpenCode has more!** |
|
||||
| Rules | PASS: 29 rules | PASS: 13 instructions | **Claude Code leads** |
|
||||
| MCP Servers | PASS: 14 servers | PASS: Full | **Full parity** |
|
||||
@@ -1441,9 +1441,9 @@ ECC is the **first plugin to maximize every major AI coding tool**. Here's how e
|
||||
|
||||
| Feature | Claude Code | Cursor IDE | Codex CLI | OpenCode |
|
||||
|---------|------------|------------|-----------|----------|
|
||||
| **Agents** | 48 | Shared (AGENTS.md) | Shared (AGENTS.md) | 12 |
|
||||
| **Agents** | 50 | Shared (AGENTS.md) | Shared (AGENTS.md) | 12 |
|
||||
| **Commands** | 68 | Shared | Instruction-based | 31 |
|
||||
| **Skills** | 182 | Shared | 10 (native format) | 37 |
|
||||
| **Skills** | 185 | Shared | 10 (native format) | 37 |
|
||||
| **Hook Events** | 8 types | 15 types | None yet | 11 types |
|
||||
| **Hook Scripts** | 20+ scripts | 16 scripts (DRY adapter) | N/A | Plugin hooks |
|
||||
| **Rules** | 34 (common + lang) | 34 (YAML frontmatter) | Instruction-based | 13 instructions |
|
||||
|
||||
+8
-8
@@ -102,12 +102,12 @@
|
||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
||||
|
||||
# 安装插件
|
||||
/plugin install everything-claude-code@everything-claude-code
|
||||
/plugin install ecc@ecc
|
||||
```
|
||||
|
||||
> 安装名称说明:较早的帖子里可能还会出现旧的短别名。那个旧缩写现在已经废弃。Anthropic 的 marketplace/plugin 安装是按规范化插件标识符寻址的,因此 ECC 统一为 `everything-claude-code@everything-claude-code`,这样市场条目、安装命令、`/plugin list` 输出和仓库文档都使用同一个公开名称,不再出现两个名字指向同一插件的混乱。
|
||||
> 安装名称说明:较早的帖子里可能还会出现较长的旧标识符。Anthropic 的 marketplace/plugin 安装是按规范化插件标识符寻址的,因此 ECC 现在统一为 `ecc@ecc`,让工具名和 slash command 命名空间保持简短。
|
||||
|
||||
### 第二步:安装规则(必需)
|
||||
### 第二步:仅在需要时安装规则
|
||||
|
||||
> WARNING: **重要提示:** Claude Code 插件无法自动分发 `rules`。
|
||||
>
|
||||
@@ -151,16 +151,16 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/"
|
||||
|
||||
```bash
|
||||
# 尝试一个命令(插件安装使用命名空间形式)
|
||||
/everything-claude-code:plan "添加用户认证"
|
||||
/ecc:plan "添加用户认证"
|
||||
|
||||
# 手动安装(选项2)使用简短形式:
|
||||
# /plan "添加用户认证"
|
||||
|
||||
# 查看可用命令
|
||||
/plugin list everything-claude-code@everything-claude-code
|
||||
/plugin list ecc@ecc
|
||||
```
|
||||
|
||||
**完成!** 你现在可以使用 48 个代理、182 个技能和 68 个命令。
|
||||
**完成!** 你现在可以使用 50 个代理、185 个技能和 68 个命令。
|
||||
|
||||
### multi-* 命令需要额外配置
|
||||
|
||||
@@ -546,7 +546,7 @@ Claude Code v2.1+ 会**按照约定自动加载**已安装插件中的 `hooks/ho
|
||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
||||
|
||||
# 安装插件
|
||||
/plugin install everything-claude-code@everything-claude-code
|
||||
/plugin install ecc@ecc
|
||||
```
|
||||
|
||||
或直接添加到你的 `~/.claude/settings.json`:
|
||||
@@ -562,7 +562,7 @@ Claude Code v2.1+ 会**按照约定自动加载**已安装插件中的 `hooks/ho
|
||||
}
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"everything-claude-code@everything-claude-code": true
|
||||
"ecc@ecc": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
---
|
||||
name: swift-build-resolver
|
||||
description: Swift/Xcode build, compilation, and dependency error resolution specialist. Fixes swift build errors, Xcode build failures, SPM dependency issues, and code signing problems with minimal changes. Use when Swift builds fail.
|
||||
tools: ["Read", "Write", "Edit", "Bash", "Grep", "Glob"]
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
# Swift Build Error Resolver
|
||||
|
||||
You are an expert Swift build error resolution specialist. Your mission is to fix Swift compilation errors, Xcode build failures, and dependency problems with **minimal, surgical changes**.
|
||||
|
||||
## Core Responsibilities
|
||||
|
||||
1. Diagnose `swift build` / `xcodebuild` errors
|
||||
2. Fix type checker and protocol conformance errors
|
||||
3. Resolve Swift Concurrency and `Sendable` issues
|
||||
4. Handle SPM dependency and version resolution failures
|
||||
5. Fix Xcode project configuration and code signing issues
|
||||
|
||||
## Diagnostic Commands
|
||||
|
||||
Run these in order:
|
||||
|
||||
```bash
|
||||
swift build 2>&1
|
||||
if command -v swiftlint >/dev/null 2>&1; then swiftlint lint --quiet 2>&1; else echo "[info] swiftlint not installed - skipping lint"; fi
|
||||
swift package resolve 2>&1
|
||||
swift package show-dependencies 2>&1
|
||||
swift test 2>&1
|
||||
```
|
||||
|
||||
For Xcode projects:
|
||||
|
||||
```bash
|
||||
xcodebuild -list 2>&1
|
||||
xcrun simctl list devices available 2>&1 | head -20 # find an available simulator
|
||||
xcodebuild -scheme <Scheme> -destination 'generic/platform=iOS Simulator' build 2>&1 | tail -50
|
||||
xcodebuild -showBuildSettings 2>&1 | grep -E 'SWIFT_VERSION|CODE_SIGN|PRODUCT_BUNDLE_IDENTIFIER'
|
||||
```
|
||||
|
||||
## Resolution Workflow
|
||||
|
||||
```text
|
||||
1. swift build -> Parse error message and error code
|
||||
2. Read affected file -> Understand type and protocol context
|
||||
3. Apply minimal fix -> Only what's needed
|
||||
4. swift build -> Verify fix
|
||||
5. swiftlint lint -> Check for warnings (if swiftlint is installed)
|
||||
6. swift test -> Ensure nothing broke
|
||||
```
|
||||
|
||||
## Common Fix Patterns
|
||||
|
||||
| Error | Cause | Fix |
|
||||
|-------|-------|-----|
|
||||
| `cannot find type 'X' in scope` | Missing import or typo | Add `import Module` or fix name |
|
||||
| `value of type 'X' has no member 'Y'` | Wrong type or missing extension | Fix type or add missing method |
|
||||
| `cannot convert value of type 'X' to expected type 'Y'` | Type mismatch | Add conversion, cast, or fix type annotation |
|
||||
| `type 'X' does not conform to protocol 'Y'` | Missing required members | Implement missing protocol requirements |
|
||||
| `missing return in closure expected to return 'X'` | Incomplete closure body | Add explicit return statement |
|
||||
| `expression is 'async' but is not marked with 'await'` | Missing `await` | Add `await` keyword |
|
||||
| `non-sendable type 'X' passed in implicitly asynchronous call` | Sendable violation | Add `Sendable` conformance or restructure |
|
||||
| `actor-isolated property cannot be referenced from non-isolated context` | Actor isolation mismatch | Add `await`, mark caller as `async`, or use `nonisolated` |
|
||||
| `reference to captured var 'X' in concurrently-executing code` | Captured mutable state | Use `let` copy before closure or actor |
|
||||
| `ambiguous use of 'X'` | Multiple matching declarations | Use fully qualified name or explicit type annotation |
|
||||
| `circular reference` | Recursive type or protocol | Break cycle with indirect enum or protocol |
|
||||
| `cannot assign to property: 'X' is a 'let' constant` | Mutating immutable value | Change `let` to `var` or restructure |
|
||||
| `initializer requires that 'X' conform to 'Decodable'` | Missing Codable conformance | Add `Codable` conformance or custom init |
|
||||
| `@MainActor function cannot be called from non-isolated context` | Main actor isolation | Add `await` and make caller `async`, or use `MainActor.run {}` |
|
||||
|
||||
## SPM Troubleshooting
|
||||
|
||||
```bash
|
||||
# Check resolved dependency versions
|
||||
cat Package.resolved | head -40
|
||||
|
||||
# Clear package caches
|
||||
swift package reset
|
||||
swift package resolve
|
||||
|
||||
# Show full dependency tree
|
||||
swift package show-dependencies --format json
|
||||
|
||||
# Update a specific dependency
|
||||
swift package update <PackageName>
|
||||
|
||||
# Check for version conflicts
|
||||
swift package resolve 2>&1 | grep -i "conflict\\|error"
|
||||
|
||||
# Verify Package.swift syntax
|
||||
swift package dump-package
|
||||
```
|
||||
|
||||
## Xcode Build Troubleshooting
|
||||
|
||||
```bash
|
||||
# Clean build folder
|
||||
xcodebuild clean -scheme <Scheme>
|
||||
|
||||
# List available schemes and destinations
|
||||
xcodebuild -list
|
||||
xcrun simctl list devices available
|
||||
|
||||
# Check Swift version
|
||||
xcrun --find swift
|
||||
swift --version
|
||||
grep 'swift-tools-version' Package.swift
|
||||
|
||||
# Code signing issues
|
||||
security find-identity -v -p codesigning
|
||||
xcodebuild -showBuildSettings | grep CODE_SIGN
|
||||
|
||||
# Module map / framework issues
|
||||
xcodebuild -scheme <Scheme> build 2>&1 | grep -E 'module|framework|import'
|
||||
```
|
||||
|
||||
## Swift Version and Toolchain Issues
|
||||
|
||||
```bash
|
||||
# Check active toolchain
|
||||
xcrun --find swift
|
||||
swift --version
|
||||
|
||||
# Check swift-tools-version in Package.swift
|
||||
head -1 Package.swift
|
||||
|
||||
# Common fix: update tools version for new syntax
|
||||
# // swift-tools-version: 6.0 (requires Xcode 16+)
|
||||
```
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Surgical fixes only** - don't refactor, just fix the error
|
||||
- **Never** add `// swiftlint:disable` without explicit approval
|
||||
- **Never** use force unwrap (`!`) to silence optionals - handle properly with `guard let` or `if let`
|
||||
- **Never** use `@unchecked Sendable` to silence concurrency errors without verifying thread safety
|
||||
- **Always** run `swift build` after every fix attempt
|
||||
- Fix root cause over suppressing symptoms
|
||||
- Prefer the simplest fix that preserves the original intent
|
||||
|
||||
## Stop Conditions
|
||||
|
||||
Stop and report if:
|
||||
- Same error persists after 3 fix attempts
|
||||
- Fix introduces more errors than it resolves
|
||||
- Error requires architectural changes beyond scope
|
||||
- Concurrency error requires redesigning actor isolation model
|
||||
- Build failure is caused by missing provisioning profile or certificate (user action required)
|
||||
|
||||
## Output Format
|
||||
|
||||
```text
|
||||
[FIXED] Sources/App/Services/UserService.swift:42
|
||||
Error: type 'UserService' does not conform to protocol 'Sendable'
|
||||
Fix: Converted mutable properties to let constants and added Sendable conformance
|
||||
Remaining errors: 3
|
||||
```
|
||||
|
||||
Final: `Build Status: SUCCESS/FAILED | Errors Fixed: N | Files Modified: list`
|
||||
|
||||
For detailed Swift patterns and rules, see rules: `swift/coding-style`, `swift/patterns`, `swift/security`. See also skill: `swift-concurrency-6-2`, `swift-actor-persistence`.
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
name: swift-reviewer
|
||||
description: Expert Swift code reviewer specializing in protocol-oriented design, value semantics, ARC memory management, Swift Concurrency, and idiomatic patterns. Use for all Swift code changes. MUST BE USED for Swift projects.
|
||||
tools: ["Read", "Grep", "Glob", "Bash"]
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
You are a senior Swift code reviewer ensuring high standards of safety, idiomatic patterns, and performance.
|
||||
|
||||
When invoked:
|
||||
1. Run `swift build`, `swiftlint lint --quiet` (if available), and `swift test` - if any fail, stop and report
|
||||
2. Run `git diff HEAD~1 -- '*.swift'` (or `git diff main...HEAD -- '*.swift'` for PR review) to see recent Swift file changes
|
||||
3. Focus on modified `.swift` files
|
||||
4. If the project has CI or merge requirements, note that review assumes a green CI and resolved merge conflicts where applicable; call out if the diff suggests otherwise.
|
||||
5. Begin review
|
||||
|
||||
## Review Priorities
|
||||
|
||||
### CRITICAL - Safety
|
||||
|
||||
- **Force unwrapping**: `value!` in production code paths - use `guard let`, `if let`, or `??`
|
||||
- **Force try**: `try!` without justification - use `do/catch` or propagate with `throws`
|
||||
- **Force cast**: `as!` without a preceding type check - use `as?` with conditional binding
|
||||
- **Hardcoded secrets**: API keys, passwords, tokens in source - use Keychain or environment variables
|
||||
- **UserDefaults for secrets**: Sensitive data in `UserDefaults` - use Keychain Services
|
||||
- **ATS disabled**: App Transport Security exceptions without justification
|
||||
- **SQL/command injection**: String interpolation in queries or shell commands - use parameterized queries
|
||||
- **Path traversal**: User-controlled paths without validation and prefix check
|
||||
- **Insecure deserialization**: Decoding untrusted data without validation or size limits
|
||||
|
||||
### CRITICAL - Error Handling
|
||||
|
||||
- **Silenced errors**: Empty `catch {}` blocks or `try?` discarding meaningful errors
|
||||
- **Missing error context**: Rethrowing without wrapping in a domain-specific error
|
||||
- **`fatalError()` for recoverable conditions**: Use `throw` for errors that callers can handle
|
||||
- **`assert` for required invariants**: `assert` is stripped in release builds (debug-only) - use `precondition` when the check must hold in release, or `throw` for public API boundaries
|
||||
- **`precondition` / `fatalError` in library code**: `precondition` crashes in both debug and release; `fatalError` crashes unconditionally in all builds - use `throw` for recoverable errors at public API boundaries
|
||||
|
||||
### HIGH - Concurrency
|
||||
|
||||
- **Data races**: Mutable shared state without actor isolation or synchronization
|
||||
- **`@Sendable` violations**: Non-`Sendable` types crossing isolation boundaries
|
||||
- **Blocking the main actor**: Synchronous I/O or `Thread.sleep` on `@MainActor` - use `Task.sleep` and async I/O
|
||||
- **Unstructured `Task {}` without cancellation**: Fire-and-forget tasks leaking - use structured concurrency (`async let`, `TaskGroup`)
|
||||
- **Actor reentrancy issues**: Assumptions about state consistency across `await` suspension points
|
||||
- **Missing `@MainActor`**: UI updates performed off the main actor
|
||||
|
||||
### HIGH - Memory Management
|
||||
|
||||
- **Strong reference cycles**: Closures capturing `self` strongly in long-lived contexts - use `[weak self]` or `[unowned self]`
|
||||
- **Delegates as strong references**: Delegate properties without `weak` - causes retain cycles
|
||||
- **Closure capture lists missing**: Escaping closures without explicit capture semantics
|
||||
- **Large value type copies**: Oversized structs copied on every assignment - consider `class` or `Cow`-like patterns
|
||||
|
||||
### HIGH - Code Quality
|
||||
|
||||
- **Large functions**: Over 50 lines
|
||||
- **Deep nesting**: More than 4 levels
|
||||
- **Wildcard switch on evolving enums**: `default:` hiding new cases - use `@unknown default`
|
||||
- **Dead code**: Unused functions, imports, or variables
|
||||
- **Non-exhaustive matching**: Catch-all where explicit handling is needed
|
||||
|
||||
### HIGH - Protocol-Oriented Design
|
||||
|
||||
- **Class inheritance where protocols suffice**: Prefer protocol conformance with default extensions
|
||||
- **`Any` / `AnyObject` abuse**: Use constrained generics or `any Protocol` / `some Protocol`
|
||||
- **Missing protocol conformance**: Types that should conform to `Equatable`, `Hashable`, `Codable`, or `Sendable`
|
||||
- **Existential over generic**: `any Protocol` parameter when `some Protocol` or generic constraint is more efficient
|
||||
|
||||
### MEDIUM - Performance
|
||||
|
||||
- **Unnecessary allocation in hot paths**: Creating objects inside tight loops
|
||||
- **Missing `reserveCapacity`**: Growing arrays when final size is known
|
||||
- **String interpolation in loops**: Repeated `String` allocation - use `append` or preallocate
|
||||
- **Unnecessary `@objc` bridging**: Swift-to-Objective-C overhead where pure Swift suffices
|
||||
- **N+1 queries**: Database or network calls inside loops - batch operations
|
||||
|
||||
### MEDIUM - Best Practices
|
||||
|
||||
- **`var` when `let` suffices**: Prefer immutable bindings
|
||||
- **`class` when `struct` suffices**: Prefer value types for data models
|
||||
- **`print()` in production code**: Use `os.Logger` or structured logging
|
||||
- **Missing access control**: Types and members defaulting to `internal` when `private` or `fileprivate` is appropriate
|
||||
- **SwiftLint warnings unaddressed**: Suppressed with `// swiftlint:disable` without justification
|
||||
- **Public API without documentation**: `public` items missing `///` doc comments
|
||||
- **Magic numbers/strings**: Use named constants or enums
|
||||
- **Stringly-typed APIs**: Use enums or dedicated types instead of raw strings
|
||||
|
||||
## Diagnostic Commands
|
||||
|
||||
```bash
|
||||
swift build
|
||||
if command -v swiftlint >/dev/null 2>&1; then swiftlint lint --quiet; else echo "[info] swiftlint not installed - skipping lint (install via 'brew install swiftlint')"; fi
|
||||
swift test
|
||||
swift package resolve
|
||||
if command -v swift-format >/dev/null 2>&1; then swift-format lint -r . 2>&1 | head -30; else echo "[info] swift-format not installed - skipping format check"; fi
|
||||
```
|
||||
|
||||
## Approval Criteria
|
||||
|
||||
- **Approve**: No CRITICAL or HIGH issues
|
||||
- **Warning**: MEDIUM issues only
|
||||
- **Block**: CRITICAL or HIGH issues found
|
||||
|
||||
For detailed Swift patterns and rules, see rules: `swift/coding-style`, `swift/patterns`, `swift/security`, `swift/testing`. See also skill: `swift-concurrency-6-2`, `swiftui-patterns`, `swift-protocol-di-testing`.
|
||||
|
||||
Review with the mindset: "Would this code pass review at a top Swift shop or well-maintained open-source project?"
|
||||
@@ -40,9 +40,18 @@ tool calls that have no matching `tool_result`.
|
||||
directly.
|
||||
- `ecc loop-status --bash-timeout-seconds 1800` adjusts the stale Bash
|
||||
threshold.
|
||||
- `ecc loop-status --exit-code` exits `2` when stale loop or tool signals are
|
||||
found, or `1` when transcripts cannot be scanned.
|
||||
- `--exit-code` with `--watch` requires `--watch-count` so watchdog scripts do
|
||||
not wait forever for a process exit.
|
||||
- `ecc loop-status --watch` refreshes status until interrupted.
|
||||
- `ecc loop-status --watch --watch-count 3 --exit-code` refreshes a bounded
|
||||
number of times, then exits with the highest status seen.
|
||||
- `ecc loop-status --watch --watch-count 3` emits a bounded watch stream for
|
||||
scripts and handoffs.
|
||||
- `ecc loop-status --watch --write-dir ~/.claude/loops` maintains
|
||||
`index.json` and per-session JSON snapshots for sibling terminals or
|
||||
watchdog scripts.
|
||||
|
||||
## Watch Mode
|
||||
|
||||
@@ -50,6 +59,18 @@ When `--watch` is present, refresh status periodically. With `--json`, each
|
||||
refresh is emitted as one JSON object per line so another terminal or script can
|
||||
consume the stream.
|
||||
|
||||
## Snapshot Files
|
||||
|
||||
Use `--write-dir <dir>` when a separate process needs to inspect loop state
|
||||
without waiting for the current Claude session to dequeue `/loop-status`. The
|
||||
CLI writes:
|
||||
|
||||
- `index.json` with one row per inspected session.
|
||||
- `<session-id>.json` with the full status payload for that session.
|
||||
|
||||
These files are snapshots of local transcript analysis. They do not control or
|
||||
timeout Claude Code runtime tool calls.
|
||||
|
||||
## Arguments
|
||||
|
||||
$ARGUMENTS:
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
||||
|
||||
# プラグインをインストール
|
||||
/plugin install everything-claude-code
|
||||
/plugin install ecc@ecc
|
||||
```
|
||||
|
||||
### ステップ2:ルールをインストール(必須)
|
||||
@@ -134,13 +134,13 @@ cp -r everything-claude-code/rules/golang/* ~/.claude/rules/
|
||||
|
||||
```bash
|
||||
# コマンドを試す(プラグインはネームスペース形式)
|
||||
/everything-claude-code:plan "ユーザー認証を追加"
|
||||
/ecc:plan "ユーザー認証を追加"
|
||||
|
||||
# 手動インストール(オプション2)は短縮形式:
|
||||
# /plan "ユーザー認証を追加"
|
||||
|
||||
# 利用可能なコマンドを確認
|
||||
/plugin list everything-claude-code@everything-claude-code
|
||||
/plugin list ecc@ecc
|
||||
```
|
||||
|
||||
**完了です!** これで13のエージェント、43のスキル、31のコマンドにアクセスできます。
|
||||
@@ -427,7 +427,7 @@ Duplicate hook file detected: ./hooks/hooks.json is already resolved to a loaded
|
||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
||||
|
||||
# プラグインをインストール
|
||||
/plugin install everything-claude-code
|
||||
/plugin install ecc@ecc
|
||||
```
|
||||
|
||||
または、`~/.claude/settings.json` に直接追加:
|
||||
@@ -443,7 +443,7 @@ Duplicate hook file detected: ./hooks/hooks.json is already resolved to a loaded
|
||||
}
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"everything-claude-code@everything-claude-code": true
|
||||
"ecc@ecc": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -17,7 +17,7 @@ Everything Claude Code プロジェクトのインタラクティブなステッ
|
||||
## 前提条件
|
||||
|
||||
このスキルは起動前に Claude Code からアクセス可能である必要があります。ブートストラップには2つの方法があります:
|
||||
1. **プラグイン経由**: `/plugin install everything-claude-code@everything-claude-code` — プラグインがこのスキルを自動的にロードします
|
||||
1. **プラグイン経由**: `/plugin install ecc@ecc` — プラグインがこのスキルを自動的にロードします
|
||||
2. **手動**: このスキルのみを `~/.claude/skills/configure-ecc/SKILL.md` にコピーし、"configure ecc" と言って起動します
|
||||
|
||||
---
|
||||
|
||||
@@ -21,7 +21,7 @@ description: 任意の自動コンパクションではなく、タスクフェ
|
||||
|
||||
## 仕組み
|
||||
|
||||
`suggest-compact.sh`スクリプトはPreToolUse(Edit/Write)で実行され:
|
||||
`suggest-compact.js`スクリプトはPreToolUse(Edit/Write)で実行され:
|
||||
|
||||
1. **ツール呼び出しを追跡** - セッション内のツール呼び出しをカウント
|
||||
2. **閾値検出** - 設定可能な閾値で提案(デフォルト:50回)
|
||||
@@ -34,13 +34,16 @@ description: 任意の自動コンパクションではなく、タスクフェ
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [{
|
||||
"matcher": "tool == \"Edit\" || tool == \"Write\"",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "~/.claude/skills/strategic-compact/suggest-compact.sh"
|
||||
}]
|
||||
}]
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Edit",
|
||||
"hooks": [{ "type": "command", "command": "node ~/.claude/scripts/hooks/suggest-compact.js" }]
|
||||
},
|
||||
{
|
||||
"matcher": "Write",
|
||||
"hooks": [{ "type": "command", "command": "node ~/.claude/scripts/hooks/suggest-compact.js" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
||||
|
||||
# 플러그인 설치
|
||||
/plugin install everything-claude-code
|
||||
/plugin install ecc@ecc
|
||||
```
|
||||
|
||||
### 2단계: 룰 설치 (필수)
|
||||
@@ -141,13 +141,13 @@ cd everything-claude-code
|
||||
|
||||
```bash
|
||||
# 커맨드 실행 (플러그인 설치 시 네임스페이스 형태 사용)
|
||||
/everything-claude-code:plan "사용자 인증 추가"
|
||||
/ecc:plan "사용자 인증 추가"
|
||||
|
||||
# 수동 설치(옵션 2) 시에는 짧은 형태를 사용:
|
||||
# /plan "사용자 인증 추가"
|
||||
|
||||
# 사용 가능한 커맨드 확인
|
||||
/plugin list everything-claude-code@everything-claude-code
|
||||
/plugin list ecc@ecc
|
||||
```
|
||||
|
||||
**끝!** 이제 16개 에이전트, 65개 스킬, 40개 커맨드를 사용할 수 있습니다.
|
||||
@@ -359,7 +359,7 @@ Claude Code v2.1+는 설치된 플러그인의 `hooks/hooks.json`을 **자동으
|
||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
||||
|
||||
# 플러그인 설치
|
||||
/plugin install everything-claude-code
|
||||
/plugin install ecc@ecc
|
||||
```
|
||||
|
||||
또는 `~/.claude/settings.json`에 직접 추가:
|
||||
@@ -375,7 +375,7 @@ Claude Code v2.1+는 설치된 플러그인의 `hooks/hooks.json`을 **자동으
|
||||
}
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"everything-claude-code@everything-claude-code": true
|
||||
"ecc@ecc": true
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -489,8 +489,8 @@ rules/
|
||||
|
||||
| 하고 싶은 것 | 사용할 커맨드 | 사용되는 에이전트 |
|
||||
|-------------|-------------|-----------------|
|
||||
| 새 기능 계획하기 | `/everything-claude-code:plan "인증 추가"` | planner |
|
||||
| 시스템 아키텍처 설계 | `/everything-claude-code:plan` + architect 에이전트 | architect |
|
||||
| 새 기능 계획하기 | `/ecc:plan "인증 추가"` | planner |
|
||||
| 시스템 아키텍처 설계 | `/ecc:plan` + architect 에이전트 | architect |
|
||||
| 테스트를 먼저 작성하며 코딩 | `/tdd` | tdd-guide |
|
||||
| 방금 작성한 코드 리뷰 | `/code-review` | code-reviewer |
|
||||
| 빌드 실패 수정 | `/build-fix` | build-error-resolver |
|
||||
@@ -507,7 +507,7 @@ rules/
|
||||
|
||||
**새로운 기능 시작:**
|
||||
```
|
||||
/everything-claude-code:plan "OAuth를 사용한 사용자 인증 추가"
|
||||
/ecc:plan "OAuth를 사용한 사용자 인증 추가"
|
||||
→ planner가 구현 청사진 작성
|
||||
/tdd → tdd-guide가 테스트 먼저 작성 강제
|
||||
/code-review → code-reviewer가 코드 검토
|
||||
@@ -535,7 +535,7 @@ rules/
|
||||
<summary><b>설치된 에이전트/커맨드 확인은 어떻게 하나요?</b></summary>
|
||||
|
||||
```bash
|
||||
/plugin list everything-claude-code@everything-claude-code
|
||||
/plugin list ecc@ecc
|
||||
```
|
||||
|
||||
플러그인에서 사용할 수 있는 모든 에이전트, 커맨드, 스킬을 보여줍니다.
|
||||
|
||||
@@ -124,7 +124,7 @@ Comece em menos de 2 minutos:
|
||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
||||
|
||||
# Instalar plugin
|
||||
/plugin install everything-claude-code@everything-claude-code
|
||||
/plugin install ecc@ecc
|
||||
```
|
||||
|
||||
### Passo 2: Instalar as Regras (Obrigatório)
|
||||
@@ -161,13 +161,13 @@ npx ecc-install typescript
|
||||
|
||||
```bash
|
||||
# Experimente um comando (a instalação do plugin usa forma com namespace)
|
||||
/everything-claude-code:plan "Adicionar autenticação de usuário"
|
||||
/ecc:plan "Adicionar autenticação de usuário"
|
||||
|
||||
# Instalação manual (Opção 2) usa a forma mais curta:
|
||||
# /plan "Adicionar autenticação de usuário"
|
||||
|
||||
# Verificar comandos disponíveis
|
||||
/plugin list everything-claude-code@everything-claude-code
|
||||
/plugin list ecc@ecc
|
||||
```
|
||||
|
||||
**Pronto!** Você agora tem acesso a 28 agentes, 116 skills e 59 comandos.
|
||||
@@ -313,7 +313,7 @@ claude --version
|
||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
||||
|
||||
# Instalar o plugin
|
||||
/plugin install everything-claude-code@everything-claude-code
|
||||
/plugin install ecc@ecc
|
||||
```
|
||||
|
||||
Ou adicione diretamente ao seu `~/.claude/settings.json`:
|
||||
@@ -329,7 +329,7 @@ Ou adicione diretamente ao seu `~/.claude/settings.json`:
|
||||
}
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"everything-claude-code@everything-claude-code": true
|
||||
"ecc@ecc": true
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -408,8 +408,8 @@ Regras são diretrizes sempre seguidas, organizadas em `common/` (agnóstico à
|
||||
|
||||
| Quero... | Use este comando | Agente usado |
|
||||
|----------|-----------------|--------------|
|
||||
| Planejar um novo recurso | `/everything-claude-code:plan "Adicionar auth"` | planner |
|
||||
| Projetar arquitetura de sistema | `/everything-claude-code:plan` + agente architect | architect |
|
||||
| Planejar um novo recurso | `/ecc:plan "Adicionar auth"` | planner |
|
||||
| Projetar arquitetura de sistema | `/ecc:plan` + agente architect | architect |
|
||||
| Escrever código com testes primeiro | `/tdd` | tdd-guide |
|
||||
| Revisar código que acabei de escrever | `/code-review` | code-reviewer |
|
||||
| Corrigir build com falha | `/build-fix` | build-error-resolver |
|
||||
@@ -424,7 +424,7 @@ Regras são diretrizes sempre seguidas, organizadas em `common/` (agnóstico à
|
||||
|
||||
**Começando um novo recurso:**
|
||||
```
|
||||
/everything-claude-code:plan "Adicionar autenticação de usuário com OAuth"
|
||||
/ecc:plan "Adicionar autenticação de usuário com OAuth"
|
||||
→ planner cria blueprint de implementação
|
||||
/tdd → tdd-guide aplica escrita de testes primeiro
|
||||
/code-review → code-reviewer verifica seu trabalho
|
||||
@@ -452,7 +452,7 @@ Regras são diretrizes sempre seguidas, organizadas em `common/` (agnóstico à
|
||||
<summary><b>Como verificar quais agentes/comandos estão instalados?</b></summary>
|
||||
|
||||
```bash
|
||||
/plugin list everything-claude-code@everything-claude-code
|
||||
/plugin list ecc@ecc
|
||||
```
|
||||
</details>
|
||||
|
||||
|
||||
+1613
File diff suppressed because it is too large
Load Diff
+7
-7
@@ -125,7 +125,7 @@ Bu repository yalnızca ham kodu içerir. Rehberler her şeyi açıklıyor.
|
||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
||||
|
||||
# Plugin'i kur
|
||||
/plugin install everything-claude-code
|
||||
/plugin install ecc@ecc
|
||||
```
|
||||
|
||||
### Adım 2: Rule'ları Kurun (Gerekli)
|
||||
@@ -164,13 +164,13 @@ Manuel kurulum talimatları için `rules/` klasöründeki README'ye bakın.
|
||||
|
||||
```bash
|
||||
# Bir command deneyin (plugin kurulumu namespace'li form kullanır)
|
||||
/everything-claude-code:plan "Kullanıcı kimlik doğrulaması ekle"
|
||||
/ecc:plan "Kullanıcı kimlik doğrulaması ekle"
|
||||
|
||||
# Manuel kurulum (Seçenek 2) daha kısa formu kullanır:
|
||||
# /plan "Kullanıcı kimlik doğrulaması ekle"
|
||||
|
||||
# Mevcut command'ları kontrol edin
|
||||
/plugin list everything-claude-code@everything-claude-code
|
||||
/plugin list ecc@ecc
|
||||
```
|
||||
|
||||
**Bu kadar!** Artık 28 agent, 116 skill ve 59 command'a erişiminiz var.
|
||||
@@ -308,8 +308,8 @@ Nereden başlayacağınızdan emin değil misiniz? Bu hızlı referansı kullan
|
||||
|
||||
| Yapmak istediğim... | Bu command'ı kullan | Kullanılan agent |
|
||||
|---------------------|---------------------|------------------|
|
||||
| Yeni bir feature planla | `/everything-claude-code:plan "Auth ekle"` | planner |
|
||||
| Sistem mimarisi tasarla | `/everything-claude-code:plan` + architect agent | architect |
|
||||
| Yeni bir feature planla | `/ecc:plan "Auth ekle"` | planner |
|
||||
| Sistem mimarisi tasarla | `/ecc:plan` + architect agent | architect |
|
||||
| Önce testlerle kod yaz | `/tdd` | tdd-guide |
|
||||
| Yazdığım kodu incele | `/code-review` | code-reviewer |
|
||||
| Başarısız bir build'i düzelt | `/build-fix` | build-error-resolver |
|
||||
@@ -324,7 +324,7 @@ Nereden başlayacağınızdan emin değil misiniz? Bu hızlı referansı kullan
|
||||
|
||||
**Yeni bir feature başlatma:**
|
||||
```
|
||||
/everything-claude-code:plan "OAuth ile kullanıcı kimlik doğrulaması ekle"
|
||||
/ecc:plan "OAuth ile kullanıcı kimlik doğrulaması ekle"
|
||||
→ planner implementasyon planı oluşturur
|
||||
/tdd → tdd-guide önce-test-yaz'ı zorunlu kılar
|
||||
/code-review → code-reviewer çalışmanızı kontrol eder
|
||||
@@ -352,7 +352,7 @@ Nereden başlayacağınızdan emin değil misiniz? Bu hızlı referansı kullan
|
||||
<summary><b>Hangi agent/command'ların kurulu olduğunu nasıl kontrol ederim?</b></summary>
|
||||
|
||||
```bash
|
||||
/plugin list everything-claude-code@everything-claude-code
|
||||
/plugin list ecc@ecc
|
||||
```
|
||||
|
||||
Bu, plugin'den mevcut tüm agent'ları, command'ları ve skill'leri gösterir.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Everything Claude Code (ECC) — 智能体指令
|
||||
|
||||
这是一个**生产就绪的 AI 编码插件**,提供 48 个专业代理、182 项技能、68 条命令以及自动化钩子工作流,用于软件开发。
|
||||
这是一个**生产就绪的 AI 编码插件**,提供 50 个专业代理、185 项技能、68 条命令以及自动化钩子工作流,用于软件开发。
|
||||
|
||||
**版本:** 2.0.0-rc.1
|
||||
|
||||
@@ -146,8 +146,8 @@
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
agents/ — 48 个专业子代理
|
||||
skills/ — 182 个工作流技能和领域知识
|
||||
agents/ — 50 个专业子代理
|
||||
skills/ — 185 个工作流技能和领域知识
|
||||
commands/ — 68 个斜杠命令
|
||||
hooks/ — 基于触发的自动化
|
||||
rules/ — 始终遵循的指导方针(通用 + 每种语言)
|
||||
|
||||
+14
-14
@@ -170,7 +170,7 @@
|
||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
||||
|
||||
# Install plugin
|
||||
/plugin install everything-claude-code@everything-claude-code
|
||||
/plugin install ecc@ecc
|
||||
```
|
||||
|
||||
### 步骤 2:安装规则(必需)
|
||||
@@ -215,16 +215,16 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/"
|
||||
|
||||
```bash
|
||||
# Try a command (plugin install uses namespaced form)
|
||||
/everything-claude-code:plan "Add user authentication"
|
||||
/ecc:plan "Add user authentication"
|
||||
|
||||
# Manual install (Option 2) uses the shorter form:
|
||||
# /plan "Add user authentication"
|
||||
|
||||
# Check available commands
|
||||
/plugin list everything-claude-code@everything-claude-code
|
||||
/plugin list ecc@ecc
|
||||
```
|
||||
|
||||
**搞定!** 你现在可以使用 48 个智能体、182 项技能和 68 个命令了。
|
||||
**搞定!** 你现在可以使用 50 个智能体、185 项技能和 68 个命令了。
|
||||
|
||||
***
|
||||
|
||||
@@ -602,7 +602,7 @@ Claude Code v2.1+ **会自动加载** 任何已安装插件中的 `hooks/hooks.j
|
||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
||||
|
||||
# Install the plugin
|
||||
/plugin install everything-claude-code
|
||||
/plugin install ecc@ecc
|
||||
```
|
||||
|
||||
或者直接添加到您的 `~/.claude/settings.json`:
|
||||
@@ -618,7 +618,7 @@ Claude Code v2.1+ **会自动加载** 任何已安装插件中的 `hooks/hooks.j
|
||||
}
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"everything-claude-code@everything-claude-code": true
|
||||
"ecc@ecc": true
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -764,8 +764,8 @@ rules/
|
||||
|
||||
| 我想要... | 使用此表面 | 使用的智能体 |
|
||||
|--------------|-----------------|------------|
|
||||
| 规划新功能 | `/everything-claude-code:plan "Add auth"` | planner |
|
||||
| 设计系统架构 | `/everything-claude-code:plan` + architect agent | architect |
|
||||
| 规划新功能 | `/ecc:plan "Add auth"` | planner |
|
||||
| 设计系统架构 | `/ecc:plan` + architect agent | architect |
|
||||
| 先写测试再写代码 | `tdd-workflow` 技能 | tdd-guide |
|
||||
| 评审我刚写的代码 | `/code-review` | code-reviewer |
|
||||
| 修复失败的构建 | `/build-fix` | build-error-resolver |
|
||||
@@ -783,7 +783,7 @@ rules/
|
||||
**开始新功能:**
|
||||
|
||||
```
|
||||
/everything-claude-code:plan "使用 OAuth 添加用户身份验证"
|
||||
/ecc:plan "使用 OAuth 添加用户身份验证"
|
||||
→ 规划器创建实现蓝图
|
||||
tdd-workflow 技能 → tdd-guide 强制执行先写测试
|
||||
/code-review → 代码审查员检查你的工作
|
||||
@@ -813,7 +813,7 @@ e2e-testing 技能 → e2e-runner: 关键用户流
|
||||
<summary><b>如何检查已安装的代理/命令?</b></summary>
|
||||
|
||||
```bash
|
||||
/plugin list everything-claude-code@everything-claude-code
|
||||
/plugin list ecc@ecc
|
||||
```
|
||||
|
||||
这会显示插件中所有可用的代理、命令和技能。
|
||||
@@ -1132,9 +1132,9 @@ opencode
|
||||
|
||||
| 功能特性 | Claude Code | OpenCode | 状态 |
|
||||
|---------|-------------|----------|--------|
|
||||
| 智能体 | PASS: 48 个 | PASS: 12 个 | **Claude Code 领先** |
|
||||
| 智能体 | PASS: 50 个 | PASS: 12 个 | **Claude Code 领先** |
|
||||
| 命令 | PASS: 68 个 | PASS: 31 个 | **Claude Code 领先** |
|
||||
| 技能 | PASS: 182 项 | PASS: 37 项 | **Claude Code 领先** |
|
||||
| 技能 | PASS: 185 项 | PASS: 37 项 | **Claude Code 领先** |
|
||||
| 钩子 | PASS: 8 种事件类型 | PASS: 11 种事件 | **OpenCode 更多!** |
|
||||
| 规则 | PASS: 29 条 | PASS: 13 条指令 | **Claude Code 领先** |
|
||||
| MCP 服务器 | PASS: 14 个 | PASS: 完整 | **完全对等** |
|
||||
@@ -1240,9 +1240,9 @@ ECC 是**第一个最大化利用每个主要 AI 编码工具的插件**。以
|
||||
|
||||
| 功能特性 | Claude Code | Cursor IDE | Codex CLI | OpenCode |
|
||||
|---------|------------|------------|-----------|----------|
|
||||
| **智能体** | 48 | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 |
|
||||
| **智能体** | 50 | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 |
|
||||
| **命令** | 68 | 共享 | 基于指令 | 31 |
|
||||
| **技能** | 182 | 共享 | 10 (原生格式) | 37 |
|
||||
| **技能** | 185 | 共享 | 10 (原生格式) | 37 |
|
||||
| **钩子事件** | 8 种类型 | 15 种类型 | 暂无 | 11 种类型 |
|
||||
| **钩子脚本** | 20+ 个脚本 | 16 个脚本 (DRY 适配器) | N/A | 插件钩子 |
|
||||
| **规则** | 34 (通用 + 语言) | 34 (YAML 前页) | 基于指令 | 13 条指令 |
|
||||
|
||||
@@ -19,7 +19,7 @@ origin: ECC
|
||||
|
||||
此技能必须在激活前对 Claude Code 可访问。有两种引导方式:
|
||||
|
||||
1. **通过插件**: `/plugin install everything-claude-code@everything-claude-code` — 插件会自动加载此技能
|
||||
1. **通过插件**: `/plugin install ecc@ecc` — 插件会自动加载此技能
|
||||
2. **手动**: 仅将此技能复制到 `~/.claude/skills/configure-ecc/SKILL.md`,然后通过说 "configure ecc" 激活
|
||||
|
||||
***
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
name: skill-stocktake
|
||||
description: "用于审计Claude技能和命令的质量。支持快速扫描(仅变更技能)和全面盘点模式,采用顺序子代理批量评估。"
|
||||
origin: ECC
|
||||
---
|
||||
|
||||
@@ -48,11 +48,11 @@ origin: ECC
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Edit",
|
||||
"hooks": [{ "type": "command", "command": "node ~/.claude/skills/strategic-compact/suggest-compact.js" }]
|
||||
"hooks": [{ "type": "command", "command": "node ~/.claude/scripts/hooks/suggest-compact.js" }]
|
||||
},
|
||||
{
|
||||
"matcher": "Write",
|
||||
"hooks": [{ "type": "command", "command": "node ~/.claude/skills/strategic-compact/suggest-compact.js" }]
|
||||
"hooks": [{ "type": "command", "command": "node ~/.claude/scripts/hooks/suggest-compact.js" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
||||
|
||||
# 安裝外掛程式
|
||||
/plugin install everything-claude-code
|
||||
/plugin install ecc@ecc
|
||||
```
|
||||
|
||||
### 第二步:安裝規則(必需)
|
||||
@@ -89,13 +89,13 @@ cp -r everything-claude-code/rules/* ~/.claude/rules/
|
||||
|
||||
```bash
|
||||
# 嘗試一個指令(外掛安裝使用命名空間形式)
|
||||
/everything-claude-code:plan "新增使用者認證"
|
||||
/ecc:plan "新增使用者認證"
|
||||
|
||||
# 手動安裝(選項2)使用簡短形式:
|
||||
# /plan "新增使用者認證"
|
||||
|
||||
# 查看可用指令
|
||||
/plugin list everything-claude-code@everything-claude-code
|
||||
/plugin list ecc@ecc
|
||||
```
|
||||
|
||||
**完成!** 您現在使用 15+ 代理程式、30+ 技能和 20+ 指令。
|
||||
@@ -270,7 +270,7 @@ everything-claude-code/
|
||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
||||
|
||||
# 安裝外掛程式
|
||||
/plugin install everything-claude-code
|
||||
/plugin install ecc@ecc
|
||||
```
|
||||
|
||||
或直接新增到您的 `~/.claude/settings.json`:
|
||||
@@ -286,7 +286,7 @@ everything-claude-code/
|
||||
}
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"everything-claude-code@everything-claude-code": true
|
||||
"ecc@ecc": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -21,7 +21,7 @@ description: Suggests manual context compaction at logical intervals to preserve
|
||||
|
||||
## 運作方式
|
||||
|
||||
`suggest-compact.sh` 腳本在 PreToolUse(Edit/Write)執行並:
|
||||
`suggest-compact.js` 腳本在 PreToolUse(Edit/Write)執行並:
|
||||
|
||||
1. **追蹤工具呼叫** - 計算工作階段中的工具呼叫次數
|
||||
2. **門檻偵測** - 在可設定門檻建議(預設:50 次呼叫)
|
||||
@@ -34,13 +34,16 @@ description: Suggests manual context compaction at logical intervals to preserve
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [{
|
||||
"matcher": "tool == \"Edit\" || tool == \"Write\"",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "~/.claude/skills/strategic-compact/suggest-compact.sh"
|
||||
}]
|
||||
}]
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Edit",
|
||||
"hooks": [{ "type": "command", "command": "node ~/.claude/scripts/hooks/suggest-compact.js" }]
|
||||
},
|
||||
{
|
||||
"matcher": "Write",
|
||||
"hooks": [{ "type": "command", "command": "node ~/.claude/scripts/hooks/suggest-compact.js" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
+23
-18
@@ -8,10 +8,11 @@ import tkinter as tk
|
||||
from tkinter import ttk, scrolledtext, messagebox
|
||||
import os
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
import webbrowser
|
||||
|
||||
from scripts.lib.ecc_dashboard_runtime import build_terminal_launch, maximize_window
|
||||
from scripts.lib.ecc_dashboard_runtime import launch_terminal, maximize_window
|
||||
|
||||
# ============================================================================
|
||||
# DATA LOADERS - Load ECC data from the project
|
||||
@@ -793,27 +794,31 @@ Project: github.com/affaan-m/everything-claude-code"""
|
||||
|
||||
def open_terminal(self):
|
||||
"""Open terminal at project path"""
|
||||
path = self.path_entry.get()
|
||||
argv, kwargs = build_terminal_launch(path)
|
||||
subprocess.Popen(argv, **kwargs)
|
||||
|
||||
path = os.path.realpath(self.path_entry.get())
|
||||
try:
|
||||
launch_terminal(path)
|
||||
except Exception as exc:
|
||||
messagebox.showerror("Error", f"Could not open terminal: {exc}")
|
||||
|
||||
def _open_project_doc(self, filename: str) -> None:
|
||||
"""Open a project document safely, constrained to the project directory."""
|
||||
base = os.path.realpath(self.path_entry.get())
|
||||
target = os.path.realpath(os.path.join(base, filename))
|
||||
if os.path.commonpath([base, target]) != base:
|
||||
messagebox.showerror("Error", "Access denied: path is outside the project directory")
|
||||
return
|
||||
if os.path.exists(target):
|
||||
webbrowser.open(Path(target).as_uri())
|
||||
else:
|
||||
messagebox.showerror("Error", f"{filename} not found")
|
||||
|
||||
def open_readme(self):
|
||||
"""Open README in default browser/reader"""
|
||||
import subprocess
|
||||
path = os.path.join(self.path_entry.get(), 'README.md')
|
||||
if os.path.exists(path):
|
||||
subprocess.Popen(['xdg-open' if os.name != 'nt' else 'start', path])
|
||||
else:
|
||||
messagebox.showerror("Error", "README.md not found")
|
||||
self._open_project_doc('README.md')
|
||||
|
||||
def open_agents(self):
|
||||
"""Open AGENTS.md"""
|
||||
import subprocess
|
||||
path = os.path.join(self.path_entry.get(), 'AGENTS.md')
|
||||
if os.path.exists(path):
|
||||
subprocess.Popen(['xdg-open' if os.name != 'nt' else 'start', path])
|
||||
else:
|
||||
messagebox.showerror("Error", "AGENTS.md not found")
|
||||
self._open_project_doc('AGENTS.md')
|
||||
|
||||
def refresh_data(self):
|
||||
"""Refresh all data"""
|
||||
|
||||
@@ -99,6 +99,9 @@ export ECC_HOOK_PROFILE=standard
|
||||
# Disable specific hook IDs (comma-separated)
|
||||
export ECC_DISABLED_HOOKS="pre:bash:tmux-reminder,post:edit:typecheck"
|
||||
|
||||
# Disable only GateGuard during setup or recovery
|
||||
export ECC_GATEGUARD=off
|
||||
|
||||
# Cap SessionStart additional context (default: 8000 chars)
|
||||
export ECC_SESSION_START_MAX_CHARS=4000
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ const AGENTS_PATH = path.join(ROOT, 'AGENTS.md');
|
||||
const README_ZH_CN_PATH = path.join(ROOT, 'README.zh-CN.md');
|
||||
const DOCS_ZH_CN_README_PATH = path.join(ROOT, 'docs', 'zh-CN', 'README.md');
|
||||
const DOCS_ZH_CN_AGENTS_PATH = path.join(ROOT, 'docs', 'zh-CN', 'AGENTS.md');
|
||||
const PLUGIN_JSON_PATH = path.join(ROOT, '.claude-plugin', 'plugin.json');
|
||||
const MARKETPLACE_JSON_PATH = path.join(ROOT, '.claude-plugin', 'marketplace.json');
|
||||
const WRITE_MODE = process.argv.includes('--write');
|
||||
|
||||
const OUTPUT_MODE = process.argv.includes('--md')
|
||||
@@ -99,6 +101,18 @@ function parseReadmeExpectations(readmeContent) {
|
||||
{ category: 'commands', mode: 'exact', expected: Number(quickStartMatch[3]), source: 'README.md quick-start summary' }
|
||||
);
|
||||
|
||||
const projectTreeAgentsMatch = readmeContent.match(/^\|\s*--\s*agents\/\s*#\s*(\d+)\s+specialized subagents for delegation\s*$/im);
|
||||
if (!projectTreeAgentsMatch) {
|
||||
throw new Error('README.md project tree is missing the agents count');
|
||||
}
|
||||
|
||||
expectations.push({
|
||||
category: 'agents',
|
||||
mode: 'exact',
|
||||
expected: Number(projectTreeAgentsMatch[1]),
|
||||
source: 'README.md project tree (agents)'
|
||||
});
|
||||
|
||||
const tablePatterns = [
|
||||
{ category: 'agents', regex: /\|\s*(?:\*\*)?Agents(?:\*\*)?\s*\|\s*(?:(?:PASS:|\u2705)\s*)?(\d+)\s+agents\s*\|/i, source: 'README.md comparison table' },
|
||||
{ category: 'commands', regex: /\|\s*(?:\*\*)?Commands(?:\*\*)?\s*\|\s*(?:(?:PASS:|\u2705)\s*)?(\d+)\s+commands\s*\|/i, source: 'README.md comparison table' },
|
||||
@@ -346,6 +360,31 @@ function parseZhAgentsDocExpectations(agentsContent) {
|
||||
return expectations;
|
||||
}
|
||||
|
||||
function parseCatalogDescriptionExpectations(content, source, getDescription) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(content);
|
||||
} catch (error) {
|
||||
throw new Error(`${source} is not valid JSON: ${error.message}`);
|
||||
}
|
||||
|
||||
const description = getDescription(parsed);
|
||||
if (typeof description !== 'string') {
|
||||
throw new Error(`${source} is missing the catalog count description`);
|
||||
}
|
||||
|
||||
const match = description.match(/(\d+)\s+agents,\s+(\d+)\s+skills,\s+(\d+)\s+legacy command shims?/i);
|
||||
if (!match) {
|
||||
throw new Error(`${source} is missing the catalog count description`);
|
||||
}
|
||||
|
||||
return [
|
||||
{ category: 'agents', mode: 'exact', expected: Number(match[1]), source },
|
||||
{ category: 'skills', mode: 'exact', expected: Number(match[2]), source },
|
||||
{ category: 'commands', mode: 'exact', expected: Number(match[3]), source },
|
||||
];
|
||||
}
|
||||
|
||||
function evaluateExpectations(catalog, expectations) {
|
||||
return expectations.map(expectation => {
|
||||
const actual = catalog[expectation.category].count;
|
||||
@@ -376,6 +415,12 @@ function syncEnglishReadme(content, catalog) {
|
||||
`${prefix}${catalog.agents.count}${agentsSuffix}${catalog.skills.count}${skillsSuffix}${catalog.commands.count} legacy command shims`,
|
||||
'README.md quick-start summary'
|
||||
);
|
||||
nextContent = replaceOrThrow(
|
||||
nextContent,
|
||||
/^(\|\s*--\s*agents\/\s*#\s*)(\d+)(\s+specialized subagents for delegation\s*)$/im,
|
||||
(_, prefix, __, suffix) => `${prefix}${catalog.agents.count}${suffix}`,
|
||||
'README.md project tree (agents)'
|
||||
);
|
||||
nextContent = replaceOrThrow(
|
||||
nextContent,
|
||||
/(\|\s*(?:\*\*)?Agents(?:\*\*)?\s*\|\s*(?:(?:PASS:|\u2705)\s*)?)(\d+)(\s+agents\s*\|)/i,
|
||||
@@ -540,6 +585,31 @@ function syncZhAgents(content, catalog) {
|
||||
return nextContent;
|
||||
}
|
||||
|
||||
function syncCatalogDescription(content, catalog, source, getDescription, setDescription) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(content);
|
||||
} catch (error) {
|
||||
throw new Error(`${source} is not valid JSON: ${error.message}`);
|
||||
}
|
||||
|
||||
const description = getDescription(parsed);
|
||||
if (typeof description !== 'string') {
|
||||
throw new Error(`${source} is missing the catalog count description`);
|
||||
}
|
||||
|
||||
const nextDescription = replaceOrThrow(
|
||||
description,
|
||||
/(\d+)(\s+agents,\s+)(\d+)(\s+skills,\s+)(\d+)(\s+legacy command shims?)/i,
|
||||
(_, __, agentsSuffix, ___, skillsSuffix, ____, commandsSuffix) =>
|
||||
`${catalog.agents.count}${agentsSuffix}${catalog.skills.count}${skillsSuffix}${catalog.commands.count}${commandsSuffix}`,
|
||||
source
|
||||
);
|
||||
|
||||
setDescription(parsed, nextDescription);
|
||||
return `${JSON.stringify(parsed, null, 2)}\n`;
|
||||
}
|
||||
|
||||
function createDocumentSpecs(paths = {}) {
|
||||
const {
|
||||
readmePath = README_PATH,
|
||||
@@ -547,6 +617,8 @@ function createDocumentSpecs(paths = {}) {
|
||||
zhRootReadmePath = README_ZH_CN_PATH,
|
||||
zhDocsReadmePath = DOCS_ZH_CN_README_PATH,
|
||||
zhDocsAgentsPath = DOCS_ZH_CN_AGENTS_PATH,
|
||||
pluginJsonPath = PLUGIN_JSON_PATH,
|
||||
marketplaceJsonPath = MARKETPLACE_JSON_PATH,
|
||||
} = paths;
|
||||
|
||||
return [
|
||||
@@ -575,6 +647,36 @@ function createDocumentSpecs(paths = {}) {
|
||||
parseExpectations: parseZhAgentsDocExpectations,
|
||||
syncContent: syncZhAgents,
|
||||
},
|
||||
{
|
||||
filePath: pluginJsonPath,
|
||||
parseExpectations: content => parseCatalogDescriptionExpectations(
|
||||
content,
|
||||
'.claude-plugin/plugin.json description',
|
||||
parsed => parsed.description
|
||||
),
|
||||
syncContent: (content, catalog) => syncCatalogDescription(
|
||||
content,
|
||||
catalog,
|
||||
'.claude-plugin/plugin.json description',
|
||||
parsed => parsed.description,
|
||||
(parsed, description) => { parsed.description = description; }
|
||||
),
|
||||
},
|
||||
{
|
||||
filePath: marketplaceJsonPath,
|
||||
parseExpectations: content => parseCatalogDescriptionExpectations(
|
||||
content,
|
||||
'.claude-plugin/marketplace.json plugin description',
|
||||
parsed => parsed.plugins?.[0]?.description
|
||||
),
|
||||
syncContent: (content, catalog) => syncCatalogDescription(
|
||||
content,
|
||||
catalog,
|
||||
'.claude-plugin/marketplace.json plugin description',
|
||||
parsed => parsed.plugins?.[0]?.description,
|
||||
(parsed, description) => { parsed.plugins[0].description = description; }
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -585,6 +687,8 @@ function createDocumentSpecsForRoot(root) {
|
||||
zhRootReadmePath: path.join(root, 'README.zh-CN.md'),
|
||||
zhDocsReadmePath: path.join(root, 'docs', 'zh-CN', 'README.md'),
|
||||
zhDocsAgentsPath: path.join(root, 'docs', 'zh-CN', 'AGENTS.md'),
|
||||
pluginJsonPath: path.join(root, '.claude-plugin', 'plugin.json'),
|
||||
marketplaceJsonPath: path.join(root, '.claude-plugin', 'marketplace.json'),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -689,11 +793,13 @@ module.exports = {
|
||||
formatExpectation,
|
||||
main,
|
||||
parseAgentsDocExpectations,
|
||||
parseCatalogDescriptionExpectations,
|
||||
parseReadmeExpectations,
|
||||
parseZhAgentsDocExpectations,
|
||||
parseZhDocsReadmeExpectations,
|
||||
parseZhRootReadmeExpectations,
|
||||
runCatalogCheck,
|
||||
syncCatalogDescription,
|
||||
syncEnglishAgents,
|
||||
syncEnglishReadme,
|
||||
syncZhAgents,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Prevent shipping user-specific absolute paths in public docs/skills/commands.
|
||||
*
|
||||
* Catches generic `/Users/<name>` (macOS) and `C:\Users\<name>` (Windows) paths,
|
||||
* while allowing obvious placeholder usernames used in templates/examples.
|
||||
* Forensic incident reports under `docs/fixes/` are exempt because they may
|
||||
* legitimately document a reporter's local machine path.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
@@ -18,11 +23,50 @@ const TARGETS = [
|
||||
'.opencode/commands',
|
||||
];
|
||||
|
||||
const BLOCK_PATTERNS = [
|
||||
/\/Users\/affoon\b/g,
|
||||
/C:\\Users\\affoon\b/gi,
|
||||
const EXEMPT_PREFIXES = [
|
||||
'docs/fixes/',
|
||||
];
|
||||
|
||||
const PLACEHOLDER_USERNAMES = new Set([
|
||||
'example',
|
||||
'me',
|
||||
'user',
|
||||
'username',
|
||||
'you',
|
||||
'yourname',
|
||||
'yourusername',
|
||||
'your-username',
|
||||
]);
|
||||
|
||||
const POSIX_USER_RE = /\/Users\/([a-zA-Z][a-zA-Z0-9._-]*)/g;
|
||||
const WIN_USER_RE = /C:\\Users\\([a-zA-Z][a-zA-Z0-9._-]*)/gi;
|
||||
|
||||
function repoRelative(file) {
|
||||
return path.relative(ROOT, file).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function isExempt(file) {
|
||||
const rel = repoRelative(file);
|
||||
return EXEMPT_PREFIXES.some(prefix => rel.startsWith(prefix));
|
||||
}
|
||||
|
||||
function findLeaks(content) {
|
||||
const leaks = [];
|
||||
|
||||
for (const pattern of [POSIX_USER_RE, WIN_USER_RE]) {
|
||||
pattern.lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = pattern.exec(content)) !== null) {
|
||||
if (!PLACEHOLDER_USERNAMES.has(match[1].toLowerCase())) {
|
||||
leaks.push(match[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return leaks;
|
||||
}
|
||||
|
||||
function collectFiles(targetPath, out) {
|
||||
if (!fs.existsSync(targetPath)) return;
|
||||
const stat = fs.statSync(targetPath);
|
||||
@@ -45,14 +89,14 @@ for (const target of TARGETS) {
|
||||
let failures = 0;
|
||||
for (const file of files) {
|
||||
if (!/\.(md|json|js|ts|sh|toml|yml|yaml)$/i.test(file)) continue;
|
||||
if (isExempt(file)) continue;
|
||||
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
for (const pattern of BLOCK_PATTERNS) {
|
||||
const match = content.match(pattern);
|
||||
if (match) {
|
||||
console.error(`ERROR: personal path detected in ${path.relative(ROOT, file)}`);
|
||||
failures += match.length;
|
||||
break;
|
||||
}
|
||||
const leaks = findLeaks(content);
|
||||
|
||||
for (const leak of leaks) {
|
||||
console.error(`ERROR: personal path "${leak}" detected in ${repoRelative(file)}`);
|
||||
failures += 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+174
-20
@@ -1,6 +1,22 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Validate curated skill directories (skills/ in repo).
|
||||
*
|
||||
* Checks:
|
||||
* 1. Each sub-directory of skills/ contains a SKILL.md file.
|
||||
* 2. SKILL.md is non-empty.
|
||||
* 3. SKILL.md frontmatter (if present) declares a `name:` field.
|
||||
* 4. SKILL.md frontmatter `description:` uses an inline scalar — not a
|
||||
* literal block scalar (`|` / `|-` / `|+`), which preserves internal
|
||||
* newlines and breaks flat-table renderers keyed off `description`.
|
||||
*
|
||||
* Frontmatter findings default to WARN so CI does not break while
|
||||
* pre-existing data defects are being cleaned up out of band (see #1663).
|
||||
* Pass `--strict` or set `CI_STRICT_SKILLS=1` to promote frontmatter
|
||||
* findings to errors (exit 1).
|
||||
*
|
||||
* Structural findings (missing/empty SKILL.md) are always errors.
|
||||
*
|
||||
* Scope: curated only. Learned/imported/evolved roots are out of scope.
|
||||
* If skills/ does not exist, exit 0 (no curated skills to validate).
|
||||
*/
|
||||
@@ -10,6 +26,144 @@ const path = require('path');
|
||||
|
||||
const SKILLS_DIR = path.join(__dirname, '../../skills');
|
||||
|
||||
const STRICT = process.argv.includes('--strict') || process.env.CI_STRICT_SKILLS === '1';
|
||||
|
||||
/**
|
||||
* Parse the leading YAML frontmatter of a markdown document.
|
||||
*
|
||||
* Returns `{ present, lines }` so callers can inspect raw lines
|
||||
* (needed to detect block-scalar `description:` values).
|
||||
*
|
||||
* Tolerant of UTF-8 BOM and CRLF line endings, matching the other
|
||||
* validators in this directory.
|
||||
*
|
||||
* @param {string} content
|
||||
* @returns {{present: boolean, lines: string[]}}
|
||||
*/
|
||||
function extractFrontmatter(content) {
|
||||
// Strip BOM if present (UTF-8 BOM: U+FEFF).
|
||||
const clean = content.replace(/^\uFEFF/, '');
|
||||
const match = clean.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
||||
if (!match) return { present: false, lines: [] };
|
||||
return {
|
||||
present: true,
|
||||
lines: match[1].split(/\r?\n/)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract top-level keys (with trimmed values) and flag block-scalar
|
||||
* `description:` values.
|
||||
*
|
||||
* Lines that continue a block scalar (`|` or `>`) are skipped — we only
|
||||
* care about the top-level key set and the raw indicator on the
|
||||
* `description:` line. Block-scalar indicators accept YAML chomp and
|
||||
* indent modifiers and trailing comments, e.g. `|`, `|-`, `|+`, `|2`,
|
||||
* `|-2`, `>- # note`.
|
||||
*
|
||||
* @param {string[]} lines
|
||||
* @returns {{values: Record<string,string>, descriptionIndicator: string|null}}
|
||||
*/
|
||||
function inspectFrontmatter(lines) {
|
||||
const values = Object.create(null);
|
||||
let descriptionIndicator = null;
|
||||
let inBlockScalar = false;
|
||||
let blockScalarIndent = -1;
|
||||
|
||||
for (const rawLine of lines) {
|
||||
if (inBlockScalar) {
|
||||
// Stay inside the block until a line with indent <= the opener's
|
||||
// indent (or an empty continuation).
|
||||
const leadingSpaces = rawLine.match(/^(\s*)/)[1].length;
|
||||
if (rawLine.trim() === '' || leadingSpaces > blockScalarIndent) {
|
||||
continue;
|
||||
}
|
||||
inBlockScalar = false;
|
||||
blockScalarIndent = -1;
|
||||
}
|
||||
|
||||
const match = rawLine.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
||||
if (!match) continue;
|
||||
|
||||
const key = match[1];
|
||||
const rawValue = match[2];
|
||||
// Strip unquoted comments for value/indicator inspection. Handles both
|
||||
// trailing comments (`foo: bar # note`) and comment-only values
|
||||
// (`foo: # todo`) so the latter is treated as empty.
|
||||
const valueNoComment = rawValue
|
||||
.replace(/^\s*#.*$/, '')
|
||||
.replace(/\s+#.*$/, '')
|
||||
.trim();
|
||||
values[key] = valueNoComment;
|
||||
|
||||
// Detect literal / folded block-scalar indicators. Accept chomp
|
||||
// modifiers (`-` / `+`) and optional indent-indicator digits in
|
||||
// either order, per YAML 1.2.
|
||||
if (/^[|>](?:[+-]?\d+|\d+[+-]?|[+-])?$/.test(valueNoComment)) {
|
||||
if (key === 'description') {
|
||||
descriptionIndicator = valueNoComment;
|
||||
}
|
||||
inBlockScalar = true;
|
||||
blockScalarIndent = rawLine.match(/^(\s*)/)[1].length;
|
||||
}
|
||||
}
|
||||
|
||||
return { values, descriptionIndicator };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single skill directory.
|
||||
*
|
||||
* Returns `{ fatal }` where `fatal` indicates a structural error that
|
||||
* should be surfaced via `console.error` and abort CI (missing/empty
|
||||
* SKILL.md). Frontmatter findings are routed through
|
||||
* `reportFrontmatterFinding`, which owns the WARN/ERROR decision based
|
||||
* on strict mode.
|
||||
*
|
||||
* @param {string} dir
|
||||
* @param {string} skillsDir
|
||||
* @param {(msg: string) => void} reportFrontmatterFinding
|
||||
* @returns {{fatal: boolean}}
|
||||
*/
|
||||
function validateSkillDir(dir, skillsDir, reportFrontmatterFinding) {
|
||||
const skillMd = path.join(skillsDir, dir, 'SKILL.md');
|
||||
if (!fs.existsSync(skillMd)) {
|
||||
console.error(`ERROR: ${dir}/ - Missing SKILL.md`);
|
||||
return { fatal: true };
|
||||
}
|
||||
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(skillMd, 'utf-8');
|
||||
} catch (err) {
|
||||
console.error(`ERROR: ${dir}/SKILL.md - ${err.message}`);
|
||||
return { fatal: true };
|
||||
}
|
||||
if (content.trim().length === 0) {
|
||||
console.error(`ERROR: ${dir}/SKILL.md - Empty file`);
|
||||
return { fatal: true };
|
||||
}
|
||||
|
||||
const fm = extractFrontmatter(content);
|
||||
if (fm.present) {
|
||||
const { values, descriptionIndicator } = inspectFrontmatter(fm.lines);
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(values, 'name')) {
|
||||
reportFrontmatterFinding(`${dir}/SKILL.md - frontmatter missing required field: name`);
|
||||
} else if (values.name === '') {
|
||||
reportFrontmatterFinding(`${dir}/SKILL.md - frontmatter 'name' is empty`);
|
||||
}
|
||||
|
||||
if (descriptionIndicator && descriptionIndicator.startsWith('|')) {
|
||||
reportFrontmatterFinding(
|
||||
`${dir}/SKILL.md - frontmatter description uses literal block scalar ` + `'${descriptionIndicator}' which preserves internal newlines; ` + `use an inline string or folded '>' scalar instead`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { fatal: false };
|
||||
}
|
||||
|
||||
function validateSkills() {
|
||||
if (!fs.existsSync(SKILLS_DIR)) {
|
||||
console.log('No curated skills directory (skills/), skipping');
|
||||
@@ -17,32 +171,28 @@ function validateSkills() {
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(SKILLS_DIR, { withFileTypes: true });
|
||||
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
|
||||
const dirs = entries.filter(e => e.isDirectory() && !e.name.startsWith('.')).map(e => e.name);
|
||||
|
||||
let hasErrors = false;
|
||||
let warnCount = 0;
|
||||
let validCount = 0;
|
||||
|
||||
const reportFrontmatterFinding = msg => {
|
||||
if (STRICT) {
|
||||
console.error(`ERROR: ${msg}`);
|
||||
hasErrors = true;
|
||||
} else {
|
||||
console.warn(`WARN: ${msg}`);
|
||||
warnCount++;
|
||||
}
|
||||
};
|
||||
|
||||
for (const dir of dirs) {
|
||||
const skillMd = path.join(SKILLS_DIR, dir, 'SKILL.md');
|
||||
if (!fs.existsSync(skillMd)) {
|
||||
console.error(`ERROR: ${dir}/ - Missing SKILL.md`);
|
||||
const { fatal } = validateSkillDir(dir, SKILLS_DIR, reportFrontmatterFinding);
|
||||
if (fatal) {
|
||||
hasErrors = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(skillMd, 'utf-8');
|
||||
} catch (err) {
|
||||
console.error(`ERROR: ${dir}/SKILL.md - ${err.message}`);
|
||||
hasErrors = true;
|
||||
continue;
|
||||
}
|
||||
if (content.trim().length === 0) {
|
||||
console.error(`ERROR: ${dir}/SKILL.md - Empty file`);
|
||||
hasErrors = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
validCount++;
|
||||
}
|
||||
|
||||
@@ -50,7 +200,11 @@ function validateSkills() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Validated ${validCount} skill directories`);
|
||||
let msg = `Validated ${validCount} skill directories`;
|
||||
if (warnCount > 0) {
|
||||
msg += ` (${warnCount} warning${warnCount === 1 ? '' : 's'})`;
|
||||
}
|
||||
console.log(msg);
|
||||
}
|
||||
|
||||
validateSkills();
|
||||
|
||||
@@ -75,7 +75,7 @@ function extractCheckoutSteps(source) {
|
||||
startLine: block.startLine,
|
||||
text: block.lines.join('\n'),
|
||||
}))
|
||||
.filter(block => /uses:\s*actions\/checkout@/m.test(block.text));
|
||||
.filter(block => /uses:\s*['"]?actions\/checkout@/m.test(block.text));
|
||||
}
|
||||
|
||||
function findViolations(filePath, source) {
|
||||
|
||||
@@ -38,11 +38,26 @@ const READ_HEARTBEAT_MS = 60 * 1000;
|
||||
const MAX_CHECKED_ENTRIES = 500;
|
||||
const MAX_SESSION_KEYS = 50;
|
||||
const ROUTINE_BASH_SESSION_KEY = '__bash_session__';
|
||||
const EDIT_WRITE_HOOK_ID = 'pre:edit-write:gateguard-fact-force';
|
||||
const BASH_HOOK_ID = 'pre:bash:gateguard-fact-force';
|
||||
const ECC_DISABLE_VALUES = new Set(['0', 'false', 'off', 'disabled', 'disable']);
|
||||
|
||||
const DESTRUCTIVE_BASH = /\b(rm\s+-rf|git\s+reset\s+--hard|git\s+checkout\s+--|git\s+clean\s+-f|drop\s+table|delete\s+from|truncate|git\s+push\s+--force(?!-with-lease)|git\s+commit\s+--amend|dd\s+if=)\b/i;
|
||||
|
||||
// --- State management (per-session, atomic writes, bounded) ---
|
||||
|
||||
function normalizeEnvValue(value) {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isGateGuardDisabled() {
|
||||
if (normalizeEnvValue(process.env.GATEGUARD_DISABLED) === '1') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ECC_DISABLE_VALUES.has(normalizeEnvValue(process.env.ECC_GATEGUARD));
|
||||
}
|
||||
|
||||
function sanitizeSessionKey(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
@@ -352,15 +367,41 @@ function routineBashMsg() {
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function withRecoveryHint(message, hookIds = [EDIT_WRITE_HOOK_ID]) {
|
||||
const disableTargets = hookIds.map(hookId => `\`${hookId}\``).join(' or ');
|
||||
return [
|
||||
message,
|
||||
'',
|
||||
`Recovery: if GateGuard is blocking setup or repair work, run this session with \`ECC_GATEGUARD=off\` or add ${disableTargets} to \`ECC_DISABLED_HOOKS\`.`
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function isSubagentInvocation(data) {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
data.agent_id,
|
||||
data.agentId,
|
||||
data.parent_tool_use_id,
|
||||
data.parentToolUseId
|
||||
];
|
||||
|
||||
return candidates.some(candidate => typeof candidate === 'string' && candidate.trim());
|
||||
}
|
||||
|
||||
// --- Deny helper ---
|
||||
|
||||
function denyResult(reason) {
|
||||
function denyResult(reason, options = {}) {
|
||||
const includeRecoveryHint = options.includeRecoveryHint !== false;
|
||||
const hookIds = Array.isArray(options.hookIds) && options.hookIds.length > 0 ? options.hookIds : [EDIT_WRITE_HOOK_ID];
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'deny',
|
||||
permissionDecisionReason: reason
|
||||
permissionDecisionReason: includeRecoveryHint ? withRecoveryHint(reason, hookIds) : reason
|
||||
}
|
||||
}),
|
||||
exitCode: 0
|
||||
@@ -383,6 +424,11 @@ function run(rawInput) {
|
||||
} catch (_) {
|
||||
return rawInput; // allow on parse error
|
||||
}
|
||||
|
||||
if (isGateGuardDisabled()) {
|
||||
return rawInput;
|
||||
}
|
||||
|
||||
activeStateFile = null;
|
||||
getStateFile(data);
|
||||
|
||||
@@ -391,6 +437,7 @@ function run(rawInput) {
|
||||
// Normalize: case-insensitive matching via lookup map
|
||||
const TOOL_MAP = { edit: 'Edit', write: 'Write', multiedit: 'MultiEdit', bash: 'Bash' };
|
||||
const toolName = TOOL_MAP[rawToolName.toLowerCase()] || rawToolName;
|
||||
const inSubagent = isSubagentInvocation(data);
|
||||
|
||||
if (toolName === 'Edit' || toolName === 'Write') {
|
||||
const filePath = toolInput.file_path || '';
|
||||
@@ -398,6 +445,10 @@ function run(rawInput) {
|
||||
return rawInput; // allow
|
||||
}
|
||||
|
||||
if (inSubagent) {
|
||||
return rawInput; // parent session already passed the first-touch file gate
|
||||
}
|
||||
|
||||
if (!isChecked(filePath)) {
|
||||
if (!markChecked(filePath)) {
|
||||
return allowWithStateWarning();
|
||||
@@ -409,6 +460,10 @@ function run(rawInput) {
|
||||
}
|
||||
|
||||
if (toolName === 'MultiEdit') {
|
||||
if (inSubagent) {
|
||||
return rawInput; // parent session already passed the first-touch file gate
|
||||
}
|
||||
|
||||
const edits = toolInput.edits || [];
|
||||
for (const edit of edits) {
|
||||
const filePath = edit.file_path || '';
|
||||
@@ -435,7 +490,7 @@ function run(rawInput) {
|
||||
if (!markChecked(key)) {
|
||||
return allowWithStateWarning();
|
||||
}
|
||||
return denyResult(destructiveBashMsg());
|
||||
return denyResult(destructiveBashMsg(), { includeRecoveryHint: false });
|
||||
}
|
||||
return rawInput; // allow retry after facts presented
|
||||
}
|
||||
@@ -444,7 +499,7 @@ function run(rawInput) {
|
||||
if (!markChecked(ROUTINE_BASH_SESSION_KEY)) {
|
||||
return allowWithStateWarning();
|
||||
}
|
||||
return denyResult(routineBashMsg());
|
||||
return denyResult(routineBashMsg(), { hookIds: [BASH_HOOK_ID] });
|
||||
}
|
||||
|
||||
return rawInput; // allow
|
||||
|
||||
@@ -306,96 +306,175 @@ function probeCommandServer(serverName, config) {
|
||||
...(config.env && typeof config.env === 'object' && !Array.isArray(config.env) ? config.env : {})
|
||||
};
|
||||
|
||||
let stderr = '';
|
||||
let done = false;
|
||||
let timer = null;
|
||||
|
||||
function finish(result) {
|
||||
if (done) return;
|
||||
done = true;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
resolve(result);
|
||||
}
|
||||
|
||||
let child;
|
||||
try {
|
||||
child = spawn(command, args, {
|
||||
env: mergedEnv,
|
||||
cwd: process.cwd(),
|
||||
stdio: ['pipe', 'ignore', 'pipe']
|
||||
});
|
||||
} catch (error) {
|
||||
finish({
|
||||
ok: false,
|
||||
statusCode: null,
|
||||
reason: error.message
|
||||
});
|
||||
return;
|
||||
}
|
||||
// On Windows, commands like 'npx' are commonly exposed as npx.cmd.
|
||||
// Probe bare PATH commands through platform-extension fallbacks, but keep
|
||||
// absolute/relative path commands as a single candidate so their existing
|
||||
// ENOENT failure semantics stay intact.
|
||||
const commandIsString = typeof command === 'string' && command.length > 0;
|
||||
const isPathLike = commandIsString && (
|
||||
path.isAbsolute(command)
|
||||
|| command.includes('/')
|
||||
|| command.includes('\\')
|
||||
);
|
||||
const candidates = process.platform === 'win32'
|
||||
&& commandIsString
|
||||
&& !path.extname(command)
|
||||
&& !isPathLike
|
||||
? [command, `${command}.cmd`, `${command}.exe`, `${command}.bat`]
|
||||
: [command];
|
||||
|
||||
child.stderr.on('data', chunk => {
|
||||
if (stderr.length < 4000) {
|
||||
const remaining = 4000 - stderr.length;
|
||||
stderr += String(chunk).slice(0, remaining);
|
||||
// cmd.exe treats these as operators, grouping syntax, expansion markers,
|
||||
// separators, or argument boundaries. Do not route such command strings
|
||||
// through shell mode.
|
||||
const UNSAFE_SHELL_CHARS = /[&|<>^%!()\s;]/;
|
||||
|
||||
function attempt(idx) {
|
||||
const tryCommand = candidates[idx];
|
||||
const isLast = idx + 1 >= candidates.length;
|
||||
let stderr = '';
|
||||
let attemptDone = false;
|
||||
let timer = null;
|
||||
|
||||
function retryNext() {
|
||||
if (attemptDone) return;
|
||||
attemptDone = true;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
attempt(idx + 1);
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', error => {
|
||||
finish({
|
||||
ok: false,
|
||||
statusCode: null,
|
||||
reason: error.message
|
||||
});
|
||||
});
|
||||
function attemptFinish(result) {
|
||||
if (attemptDone) return;
|
||||
attemptDone = true;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
finish(result);
|
||||
}
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
finish({
|
||||
ok: false,
|
||||
statusCode: code,
|
||||
reason: stderr.trim() || `process exited before handshake (${signal || code || 'unknown'})`
|
||||
});
|
||||
});
|
||||
// Node 18.20+/20.12+ refuse to spawn .cmd/.bat directly on Windows
|
||||
// after the CVE-2024-27980 mitigation. Only those extension candidates
|
||||
// go through cmd.exe, after the command string is shell-character clean.
|
||||
const useShell = process.platform === 'win32'
|
||||
&& typeof tryCommand === 'string'
|
||||
&& /\.(cmd|bat)$/i.test(tryCommand)
|
||||
&& !UNSAFE_SHELL_CHARS.test(tryCommand);
|
||||
|
||||
timer = setTimeout(() => {
|
||||
// A fast-crashing stdio server can finish before the timer callback runs
|
||||
// on a loaded machine. Check the process state again before classifying it
|
||||
// as healthy on timeout.
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
finish({
|
||||
let child;
|
||||
try {
|
||||
child = spawn(tryCommand, args, {
|
||||
env: mergedEnv,
|
||||
cwd: process.cwd(),
|
||||
stdio: ['pipe', 'ignore', 'pipe'],
|
||||
shell: useShell
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error.code === 'ENOENT' || error.code === 'EINVAL') && !isLast) {
|
||||
retryNext();
|
||||
return;
|
||||
}
|
||||
attemptFinish({
|
||||
ok: false,
|
||||
statusCode: child.exitCode,
|
||||
reason: stderr.trim() || `process exited before handshake (${child.signalCode || child.exitCode || 'unknown'})`
|
||||
statusCode: null,
|
||||
reason: error.message
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
child.kill('SIGTERM');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
child.stderr.on('data', chunk => {
|
||||
if (stderr.length < 4000) {
|
||||
const remaining = 4000 - stderr.length;
|
||||
stderr += String(chunk).slice(0, remaining);
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', error => {
|
||||
if ((error.code === 'ENOENT' || error.code === 'EINVAL') && !isLast) {
|
||||
retryNext();
|
||||
return;
|
||||
}
|
||||
attemptFinish({
|
||||
ok: false,
|
||||
statusCode: null,
|
||||
reason: error.message
|
||||
});
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
attemptFinish({
|
||||
ok: false,
|
||||
statusCode: code,
|
||||
reason: stderr.trim() || `process exited before handshake (${signal || code || 'unknown'})`
|
||||
});
|
||||
});
|
||||
|
||||
timer = setTimeout(() => {
|
||||
// A fast-crashing stdio server can finish before the timer callback runs
|
||||
// on a loaded machine. Check the process state again before classifying it
|
||||
// as healthy on timeout.
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
attemptFinish({
|
||||
ok: false,
|
||||
statusCode: child.exitCode,
|
||||
reason: stderr.trim() || `process exited before handshake (${child.signalCode || child.exitCode || 'unknown'})`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
try {
|
||||
child.kill('SIGKILL');
|
||||
if (useShell && child.pid && process.platform === 'win32') {
|
||||
// When spawned via shell on Windows, child is cmd.exe. kill() only
|
||||
// terminates the shell and leaves the real server process orphaned.
|
||||
// taskkill /T kills the entire process tree rooted at cmd.exe.
|
||||
const killResult = spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
});
|
||||
if (killResult.error || (typeof killResult.status === 'number' && killResult.status !== 0)) {
|
||||
// taskkill not on PATH, permission denied, or already exited.
|
||||
// Best-effort fallback: signal the cmd.exe shell directly. The
|
||||
// child tree may still leak if it already detached, but this at
|
||||
// least kills the shell we spawned.
|
||||
try { child.kill('SIGKILL'); } catch { /* ignore */ }
|
||||
}
|
||||
} else {
|
||||
child.kill('SIGTERM');
|
||||
setTimeout(() => {
|
||||
try {
|
||||
child.kill('SIGKILL');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 200).unref?.();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 200).unref?.();
|
||||
|
||||
finish({
|
||||
ok: true,
|
||||
statusCode: null,
|
||||
reason: `${serverName} accepted a new stdio process`
|
||||
});
|
||||
}, timeoutMs);
|
||||
attemptFinish({
|
||||
ok: true,
|
||||
statusCode: null,
|
||||
reason: `${serverName} accepted a new stdio process`
|
||||
});
|
||||
}, timeoutMs);
|
||||
|
||||
if (typeof timer.unref === 'function') {
|
||||
timer.unref();
|
||||
if (typeof timer.unref === 'function') {
|
||||
timer.unref();
|
||||
}
|
||||
}
|
||||
|
||||
attempt(0);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ const { execFileSync, spawnSync } = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
// Shell metacharacters that cmd.exe interprets as command separators/operators
|
||||
const UNSAFE_PATH_CHARS = /[&|<>^%!]/;
|
||||
const UNSAFE_PATH_CHARS = /[&|<>^%!;`()$]/;
|
||||
|
||||
const { findProjectRoot, detectFormatter, resolveFormatterBin } = require('../lib/resolve-formatter');
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
*/
|
||||
|
||||
const {
|
||||
getClaudeDir,
|
||||
getSessionsDir,
|
||||
getSessionSearchDirs,
|
||||
getLearnedSkillsDir,
|
||||
@@ -21,7 +20,7 @@ const {
|
||||
stripAnsi,
|
||||
log
|
||||
} = require('../lib/utils');
|
||||
const { resolveProjectContext, writeSessionLease, resolveSessionId } = require('../lib/observer-sessions');
|
||||
const { resolveProjectContext, writeSessionLease, resolveSessionId, getHomunculusDir } = require('../lib/observer-sessions');
|
||||
const { getPackageManager, getSelectionPrompt } = require('../lib/package-manager');
|
||||
const { listAliases } = require('../lib/session-aliases');
|
||||
const { detectProjectType } = require('../lib/project-detect');
|
||||
@@ -325,7 +324,7 @@ function extractInstinctAction(content) {
|
||||
}
|
||||
|
||||
function summarizeActiveInstincts(observerContext) {
|
||||
const homunculusDir = path.join(getClaudeDir(), 'homunculus');
|
||||
const homunculusDir = getHomunculusDir();
|
||||
const globalDirs = [
|
||||
{ dir: path.join(homunculusDir, 'instincts', 'personal'), scope: 'global' },
|
||||
{ dir: path.join(homunculusDir, 'instincts', 'inherited'), scope: 'global' },
|
||||
|
||||
@@ -18,14 +18,30 @@ const path = require('path');
|
||||
const {
|
||||
getTempDir,
|
||||
writeFile,
|
||||
readStdinJson,
|
||||
log
|
||||
} = require('../lib/utils');
|
||||
|
||||
async function resolveSessionId() {
|
||||
// Claude Code passes hook input via stdin JSON; session_id is the
|
||||
// canonical field. Fall back to the legacy env var, then 'default'.
|
||||
try {
|
||||
const input = await readStdinJson({ timeoutMs: 1000 });
|
||||
if (input && typeof input.session_id === 'string' && input.session_id) {
|
||||
return input.session_id;
|
||||
}
|
||||
} catch {
|
||||
/* fall through to env */
|
||||
}
|
||||
return process.env.CLAUDE_SESSION_ID || 'default';
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Track tool call count (increment in a temp file)
|
||||
// Use a session-specific counter file based on session ID from environment
|
||||
// or parent PID as fallback
|
||||
const sessionId = (process.env.CLAUDE_SESSION_ID || 'default').replace(/[^a-zA-Z0-9_-]/g, '') || 'default';
|
||||
// Use a session-specific counter file based on session ID from stdin JSON,
|
||||
// legacy env var, or 'default' as fallback.
|
||||
const rawSessionId = await resolveSessionId();
|
||||
const sessionId = rawSessionId.replace(/[^a-zA-Z0-9_-]/g, '') || 'default';
|
||||
const counterFile = path.join(getTempDir(), `claude-tool-count-${sessionId}`);
|
||||
const rawThreshold = parseInt(process.env.COMPACT_THRESHOLD || '50', 10);
|
||||
const threshold = Number.isFinite(rawThreshold) && rawThreshold > 0 && rawThreshold <= 10000
|
||||
|
||||
@@ -24,6 +24,7 @@ function getHelpText() {
|
||||
Usage: install.sh [--target <${LEGACY_INSTALL_TARGETS.join('|')}>] [--dry-run] [--json] <language> [<language> ...]
|
||||
install.sh [--target <${SUPPORTED_INSTALL_TARGETS.join('|')}>] [--dry-run] [--json] --profile <name> [--with <component>]... [--without <component>]...
|
||||
install.sh [--target <${SUPPORTED_INSTALL_TARGETS.join('|')}>] [--dry-run] [--json] --modules <id,id,...> [--with <component>]... [--without <component>]...
|
||||
install.sh [--target <${SUPPORTED_INSTALL_TARGETS.join('|')}>] [--dry-run] [--json] --skills <skill-id[,skill-id...]>
|
||||
install.sh [--dry-run] [--json] --config <path>
|
||||
|
||||
Targets:
|
||||
@@ -35,6 +36,7 @@ Options:
|
||||
--profile <name> Resolve and install a manifest profile
|
||||
--modules <ids> Resolve and install explicit module IDs
|
||||
--with <component> Include a user-facing install component
|
||||
--skills <ids> Install one or more skill directories by ID, e.g. continuous-learning-v2
|
||||
--without <component>
|
||||
Exclude a user-facing install component
|
||||
--config <path> Load install intent from ecc-install.json
|
||||
|
||||
@@ -25,6 +25,7 @@ Usage:
|
||||
node scripts/install-plan.js --list-components [--family <family>] [--target <target>] [--json]
|
||||
node scripts/install-plan.js --profile <name> [--with <component>]... [--without <component>]... [--target <target>] [--json]
|
||||
node scripts/install-plan.js --modules <id,id,...> [--with <component>]... [--without <component>]... [--target <target>] [--json]
|
||||
node scripts/install-plan.js --skills <skill-id[,skill-id...]> [--target <target>] [--json]
|
||||
node scripts/install-plan.js --config <path> [--json]
|
||||
|
||||
Options:
|
||||
@@ -35,6 +36,7 @@ Options:
|
||||
--profile <name> Resolve an install profile
|
||||
--modules <ids> Resolve explicit module IDs (comma-separated)
|
||||
--with <component> Include a user-facing install component
|
||||
--skills <ids> Include one or more skill components by directory ID
|
||||
--without <component>
|
||||
Exclude a user-facing install component
|
||||
--config <path> Load install intent from ecc-install.json
|
||||
@@ -61,6 +63,11 @@ function parseArgs(argv) {
|
||||
listComponents: false,
|
||||
};
|
||||
|
||||
function normalizeSkillComponentIds(rawValue) {
|
||||
return [...new Set(String(rawValue || '').split(',').map(value => value.trim()).filter(Boolean))]
|
||||
.map(value => (value.startsWith('skill:') ? value : `skill:${value}`));
|
||||
}
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
@@ -89,6 +96,9 @@ function parseArgs(argv) {
|
||||
parsed.includeComponentIds.push(componentId.trim());
|
||||
}
|
||||
index += 1;
|
||||
} else if (arg === '--skill' || arg === '--skills') {
|
||||
parsed.includeComponentIds.push(...normalizeSkillComponentIds(args[index + 1] || ''));
|
||||
index += 1;
|
||||
} else if (arg === '--without') {
|
||||
const componentId = args[index + 1] || '';
|
||||
if (componentId.trim()) {
|
||||
|
||||
@@ -45,7 +45,7 @@ def build_terminal_launch(
|
||||
if resolved_os_name == 'nt':
|
||||
creationflags = getattr(subprocess, 'CREATE_NEW_CONSOLE', 0)
|
||||
return (
|
||||
['cmd.exe', '/k', 'cd', '/d', path],
|
||||
['cmd.exe'],
|
||||
{
|
||||
'cwd': path,
|
||||
'creationflags': creationflags,
|
||||
@@ -59,3 +59,12 @@ def build_terminal_launch(
|
||||
['x-terminal-emulator', '-e', 'bash', '-lc', 'cd -- "$1"; exec bash', 'bash', path],
|
||||
{},
|
||||
)
|
||||
|
||||
|
||||
def launch_terminal(path: str) -> None:
|
||||
"""Open a terminal at the given path after validating the target directory."""
|
||||
canonical = os.path.realpath(path)
|
||||
if not os.path.isdir(canonical):
|
||||
raise ValueError(f"Path is not a valid directory: {canonical!r}")
|
||||
argv, kwargs = build_terminal_launch(canonical)
|
||||
subprocess.Popen(argv, **kwargs) # noqa: S603 - list argv, no shell=True, path validated above
|
||||
|
||||
@@ -78,6 +78,56 @@ function dedupeStrings(values) {
|
||||
return [...new Set((Array.isArray(values) ? values : []).map(value => String(value).trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function listSkillDirectoryIds(repoRoot) {
|
||||
const skillsRoot = path.join(repoRoot, 'skills');
|
||||
if (!fs.existsSync(skillsRoot) || !fs.statSync(skillsRoot).isDirectory()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs.readdirSync(skillsRoot, { withFileTypes: true })
|
||||
.filter(entry => entry.isDirectory())
|
||||
.map(entry => entry.name)
|
||||
.sort();
|
||||
}
|
||||
|
||||
function addSyntheticSkillComponents({ repoRoot, modules, components }) {
|
||||
const moduleIds = new Set(modules.map(module => module.id));
|
||||
const componentIds = new Set(components.map(component => component.id));
|
||||
|
||||
for (const skillId of listSkillDirectoryIds(repoRoot)) {
|
||||
const componentId = `skill:${skillId}`;
|
||||
if (componentIds.has(componentId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const moduleId = `skill-${skillId}`;
|
||||
if (!moduleIds.has(moduleId)) {
|
||||
modules.push({
|
||||
id: moduleId,
|
||||
kind: 'skills',
|
||||
description: `Single-skill install surface for ${skillId}.`,
|
||||
paths: [`skills/${skillId}`],
|
||||
targets: SUPPORTED_INSTALL_TARGETS.slice(),
|
||||
dependencies: [],
|
||||
defaultInstall: false,
|
||||
cost: 'light',
|
||||
stability: 'stable',
|
||||
synthetic: true,
|
||||
});
|
||||
moduleIds.add(moduleId);
|
||||
}
|
||||
|
||||
components.push({
|
||||
id: componentId,
|
||||
family: 'skill',
|
||||
description: `Install only the ${skillId} skill directory.`,
|
||||
modules: [moduleId],
|
||||
synthetic: true,
|
||||
});
|
||||
componentIds.add(componentId);
|
||||
}
|
||||
}
|
||||
|
||||
function readOptionalStringOption(options, key) {
|
||||
if (
|
||||
!Object.prototype.hasOwnProperty.call(options, key)
|
||||
@@ -164,11 +214,13 @@ function loadInstallManifests(options = {}) {
|
||||
const componentsData = fs.existsSync(componentsPath)
|
||||
? readJson(componentsPath, 'install-components.json')
|
||||
: { version: null, components: [] };
|
||||
const modules = Array.isArray(modulesData.modules) ? modulesData.modules : [];
|
||||
const modules = Array.isArray(modulesData.modules) ? modulesData.modules.slice() : [];
|
||||
const profiles = profilesData && typeof profilesData.profiles === 'object'
|
||||
? profilesData.profiles
|
||||
: {};
|
||||
const components = Array.isArray(componentsData.components) ? componentsData.components : [];
|
||||
const components = Array.isArray(componentsData.components) ? componentsData.components.slice() : [];
|
||||
|
||||
addSyntheticSkillComponents({ repoRoot, modules, components });
|
||||
|
||||
for (const module of modules) {
|
||||
readModuleTargetsOrThrow(module);
|
||||
|
||||
@@ -8,6 +8,12 @@ function dedupeStrings(values) {
|
||||
return [...new Set((Array.isArray(values) ? values : []).map(value => String(value).trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function normalizeSkillComponentIds(rawValue) {
|
||||
return dedupeStrings(String(rawValue || '').split(',')).map(value => (
|
||||
value.startsWith('skill:') ? value : `skill:${value}`
|
||||
));
|
||||
}
|
||||
|
||||
function parseInstallArgs(argv) {
|
||||
const args = argv.slice(2);
|
||||
const parsed = {
|
||||
@@ -45,6 +51,9 @@ function parseInstallArgs(argv) {
|
||||
parsed.includeComponentIds.push(componentId.trim());
|
||||
}
|
||||
index += 1;
|
||||
} else if (arg === '--skill' || arg === '--skills') {
|
||||
parsed.includeComponentIds.push(...normalizeSkillComponentIds(args[index + 1] || ''));
|
||||
index += 1;
|
||||
} else if (arg === '--without') {
|
||||
const componentId = args[index + 1] || '';
|
||||
if (componentId.trim()) {
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { spawnSync } = require('child_process');
|
||||
const { getClaudeDir, ensureDir, sanitizeSessionId } = require('./utils');
|
||||
const { ensureDir, sanitizeSessionId } = require('./utils');
|
||||
|
||||
function getHomunculusDir() {
|
||||
return path.join(getClaudeDir(), 'homunculus');
|
||||
const override = process.env.CLV2_HOMUNCULUS_DIR;
|
||||
if (override) {
|
||||
if (path.isAbsolute(override)) {
|
||||
return override;
|
||||
}
|
||||
process.stderr.write(`[ecc] CLV2_HOMUNCULUS_DIR=${override} is not absolute; ignoring\n`);
|
||||
}
|
||||
|
||||
const xdgDataHome = process.env.XDG_DATA_HOME;
|
||||
if (xdgDataHome) {
|
||||
if (path.isAbsolute(xdgDataHome)) {
|
||||
return path.join(xdgDataHome, 'ecc-homunculus');
|
||||
}
|
||||
process.stderr.write(`[ecc] XDG_DATA_HOME=${xdgDataHome} is not absolute; ignoring\n`);
|
||||
}
|
||||
|
||||
return path.join(os.homedir(), '.local', 'share', 'ecc-homunculus');
|
||||
}
|
||||
|
||||
function getProjectsDir() {
|
||||
@@ -39,6 +56,23 @@ function stripRemoteCredentials(remoteUrl) {
|
||||
return String(remoteUrl).replace(/:\/\/[^@]+@/, '://');
|
||||
}
|
||||
|
||||
function normalizeRemoteUrl(remoteUrl) {
|
||||
if (!remoteUrl) return '';
|
||||
const raw = String(remoteUrl);
|
||||
const isNetwork = !raw.startsWith('file://') && (raw.includes('://') || /^[^@/:]+@[^:/]+:/.test(raw));
|
||||
let normalized = stripRemoteCredentials(raw)
|
||||
.replace(/^[A-Za-z][A-Za-z0-9+.-]*:\/\//, '')
|
||||
.replace(/^[^@/:]+@([^:/]+):/, '$1/')
|
||||
.replace(/\.git\/?$/, '')
|
||||
.replace(/\/+$/, '');
|
||||
|
||||
if (isNetwork) {
|
||||
normalized = normalized.toLowerCase();
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolveProjectRoot(cwd = process.cwd()) {
|
||||
const envRoot = process.env.CLAUDE_PROJECT_DIR;
|
||||
if (envRoot && fs.existsSync(envRoot)) {
|
||||
@@ -53,7 +87,8 @@ function resolveProjectRoot(cwd = process.cwd()) {
|
||||
|
||||
function computeProjectId(projectRoot) {
|
||||
const remoteUrl = stripRemoteCredentials(runGit(['remote', 'get-url', 'origin'], projectRoot));
|
||||
return crypto.createHash('sha256').update(remoteUrl || projectRoot).digest('hex').slice(0, 12);
|
||||
const hashInput = normalizeRemoteUrl(remoteUrl) || remoteUrl || projectRoot;
|
||||
return crypto.createHash('sha256').update(hashInput).digest('hex').slice(0, 12);
|
||||
}
|
||||
|
||||
function resolveProjectContext(cwd = process.cwd()) {
|
||||
@@ -163,6 +198,8 @@ function stopObserverForContext(context) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getHomunculusDir,
|
||||
normalizeRemoteUrl,
|
||||
resolveProjectContext,
|
||||
getObserverActivityFile,
|
||||
getObserverPidFile,
|
||||
|
||||
+166
-3
@@ -4,6 +4,7 @@
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const DEFAULT_BASH_TIMEOUT_SECONDS = 30 * 60;
|
||||
const DEFAULT_LIMIT = 10;
|
||||
@@ -24,9 +25,11 @@ function usage() {
|
||||
' --bash-timeout-seconds <n> Age before a pending Bash call is stale (default: 1800)',
|
||||
' --wake-grace-multiplier <n> ScheduleWakeup grace multiplier (default: 2)',
|
||||
' --now <time> Override current time (ISO, epoch ms, or "now")',
|
||||
' --exit-code Exit 2 on attention signals, 1 on scan errors',
|
||||
' --watch Refresh status until interrupted',
|
||||
' --watch-count <n> Stop after n watch refreshes',
|
||||
' --watch-interval-seconds <n> Seconds between watch refreshes (default: 5)',
|
||||
' --write-dir <dir> Write index.json and per-session status snapshots',
|
||||
'',
|
||||
'Examples:',
|
||||
' node scripts/loop-status.js --json',
|
||||
@@ -62,6 +65,7 @@ function parseArgs(argv) {
|
||||
const args = argv.slice(2);
|
||||
const options = {
|
||||
bashTimeoutSeconds: DEFAULT_BASH_TIMEOUT_SECONDS,
|
||||
exitCode: false,
|
||||
home: null,
|
||||
json: false,
|
||||
limit: DEFAULT_LIMIT,
|
||||
@@ -72,6 +76,7 @@ function parseArgs(argv) {
|
||||
watchCount: null,
|
||||
wakeGraceMultiplier: DEFAULT_WAKE_GRACE_MULTIPLIER,
|
||||
watchIntervalSeconds: DEFAULT_WATCH_INTERVAL_SECONDS,
|
||||
writeDir: null,
|
||||
};
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
@@ -99,6 +104,8 @@ function parseArgs(argv) {
|
||||
} else if (arg === '--now') {
|
||||
options.now = readValue(args, index, arg);
|
||||
index += 1;
|
||||
} else if (arg === '--exit-code') {
|
||||
options.exitCode = true;
|
||||
} else if (arg === '--watch') {
|
||||
options.watch = true;
|
||||
} else if (arg === '--watch-count') {
|
||||
@@ -107,11 +114,18 @@ function parseArgs(argv) {
|
||||
} else if (arg === '--watch-interval-seconds') {
|
||||
options.watchIntervalSeconds = readPositiveNumber(readValue(args, index, arg), arg);
|
||||
index += 1;
|
||||
} else if (arg === '--write-dir') {
|
||||
options.writeDir = readValue(args, index, arg);
|
||||
index += 1;
|
||||
} else {
|
||||
throw new Error(`Unknown option: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.exitCode && options.watch && options.watchCount === null) {
|
||||
throw new Error('--exit-code with --watch requires --watch-count so the process can exit');
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
@@ -119,12 +133,14 @@ function normalizeOptions(options = {}) {
|
||||
return {
|
||||
...options,
|
||||
bashTimeoutSeconds: options.bashTimeoutSeconds ?? DEFAULT_BASH_TIMEOUT_SECONDS,
|
||||
exitCode: Boolean(options.exitCode),
|
||||
limit: options.limit ?? DEFAULT_LIMIT,
|
||||
transcriptPaths: options.transcriptPaths || [],
|
||||
watch: Boolean(options.watch),
|
||||
watchCount: options.watchCount ?? null,
|
||||
wakeGraceMultiplier: options.wakeGraceMultiplier ?? DEFAULT_WAKE_GRACE_MULTIPLIER,
|
||||
watchIntervalSeconds: options.watchIntervalSeconds ?? DEFAULT_WATCH_INTERVAL_SECONDS,
|
||||
writeDir: options.writeDir || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -594,6 +610,126 @@ function formatText(payload) {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function hashString(value) {
|
||||
return crypto.createHash('sha256').update(String(value)).digest('hex');
|
||||
}
|
||||
|
||||
function isWindowsReservedBasename(value) {
|
||||
const basename = String(value).split('.')[0];
|
||||
return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(basename);
|
||||
}
|
||||
|
||||
function sanitizeSnapshotName(value, fallback = 'session') {
|
||||
const raw = String(value || '').trim() || fallback;
|
||||
const sanitized = raw.replace(/[^a-zA-Z0-9._-]/g, '_').replace(/^_+|_+$/g, '');
|
||||
if (sanitized && sanitized.length <= 96 && !isWindowsReservedBasename(sanitized)) {
|
||||
return sanitized;
|
||||
}
|
||||
if (sanitized && isWindowsReservedBasename(sanitized)) {
|
||||
const firstDotIndex = sanitized.indexOf('.');
|
||||
const hashSuffix = hashString(raw).slice(0, 8);
|
||||
if (firstDotIndex === -1) {
|
||||
return `${sanitized}-${hashSuffix}`;
|
||||
}
|
||||
return `${sanitized.slice(0, firstDotIndex)}-${hashSuffix}${sanitized.slice(firstDotIndex)}`;
|
||||
}
|
||||
|
||||
const prefix = sanitized ? sanitized.slice(0, 48).replace(/[._-]+$/g, '') : fallback;
|
||||
return `${prefix || fallback}-${hashString(raw).slice(0, 12)}`;
|
||||
}
|
||||
|
||||
function atomicWriteJson(filePath, payload) {
|
||||
const data = JSON.stringify(payload, null, 2) + '\n';
|
||||
const tempPath = `${filePath}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
|
||||
fs.writeFileSync(tempPath, data, 'utf8');
|
||||
try {
|
||||
fs.renameSync(tempPath, filePath);
|
||||
} catch (error) {
|
||||
try {
|
||||
fs.unlinkSync(tempPath);
|
||||
} catch (cleanupError) {
|
||||
if (cleanupError.code !== 'ENOENT') {
|
||||
console.error(`[loop-status] WARNING: could not remove temporary snapshot file ${tempPath}: ${cleanupError.message}`);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function getSnapshotPath(outputDir, session, usedNames) {
|
||||
const baseName = sanitizeSnapshotName(session.sessionId);
|
||||
const hashSuffix = hashString(session.transcriptPath || session.sessionId).slice(0, 8);
|
||||
let attempt = 0;
|
||||
|
||||
while (attempt < 1000) {
|
||||
const suffix = attempt === 0 ? '' : `-${hashSuffix}${attempt === 1 ? '' : `-${attempt}`}`;
|
||||
const fileName = `${baseName}${suffix}.json`;
|
||||
if (!usedNames.has(fileName)) {
|
||||
usedNames.add(fileName);
|
||||
return path.join(outputDir, fileName);
|
||||
}
|
||||
attempt += 1;
|
||||
}
|
||||
|
||||
throw new Error(`Could not allocate a snapshot filename for session ${session.sessionId}`);
|
||||
}
|
||||
|
||||
function writeStatusSnapshots(payload, writeDir) {
|
||||
if (!writeDir) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const outputDir = path.resolve(writeDir);
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const usedNames = new Set(['index.json']);
|
||||
const sessions = payload.sessions.map(session => {
|
||||
const snapshotPath = getSnapshotPath(outputDir, session, usedNames);
|
||||
atomicWriteJson(snapshotPath, {
|
||||
generatedAt: payload.generatedAt,
|
||||
schemaVersion: 'ecc.loop-status.session.v1',
|
||||
session,
|
||||
});
|
||||
|
||||
return {
|
||||
lastEventAt: session.lastEventAt,
|
||||
sessionId: session.sessionId,
|
||||
signalTypes: session.signals.map(signal => signal.type),
|
||||
snapshotPath,
|
||||
state: session.state,
|
||||
transcriptPath: session.transcriptPath,
|
||||
};
|
||||
});
|
||||
|
||||
const indexPath = path.join(outputDir, 'index.json');
|
||||
atomicWriteJson(indexPath, {
|
||||
errors: payload.errors,
|
||||
generatedAt: payload.generatedAt,
|
||||
schemaVersion: 'ecc.loop-status.index.v1',
|
||||
sessionCount: payload.sessions.length,
|
||||
sessions,
|
||||
source: payload.source,
|
||||
});
|
||||
|
||||
return {
|
||||
indexPath,
|
||||
sessionCount: payload.sessions.length,
|
||||
};
|
||||
}
|
||||
|
||||
function tryWriteStatusSnapshots(payload, options) {
|
||||
if (!options.writeDir) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return writeStatusSnapshots(payload, options.writeDir);
|
||||
} catch (error) {
|
||||
console.error(`[loop-status] WARNING: could not write status snapshots: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -606,15 +742,29 @@ function writeStatus(payload, options) {
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusExitCode(payload) {
|
||||
if (payload.sessions.some(session => session.state === 'attention')) {
|
||||
return 2;
|
||||
}
|
||||
if (payload.errors.length > 0) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function runWatch(options) {
|
||||
const normalizedOptions = normalizeOptions(options);
|
||||
let iteration = 0;
|
||||
let exitCode = 0;
|
||||
|
||||
while (normalizedOptions.watchCount === null || iteration < normalizedOptions.watchCount) {
|
||||
if (iteration > 0 && !normalizedOptions.json) {
|
||||
console.log('');
|
||||
}
|
||||
writeStatus(buildStatus(normalizedOptions), normalizedOptions);
|
||||
const payload = buildStatus(normalizedOptions);
|
||||
tryWriteStatusSnapshots(payload, normalizedOptions);
|
||||
writeStatus(payload, normalizedOptions);
|
||||
exitCode = Math.max(exitCode, getStatusExitCode(payload));
|
||||
iteration += 1;
|
||||
|
||||
if (normalizedOptions.watchCount !== null && iteration >= normalizedOptions.watchCount) {
|
||||
@@ -623,6 +773,8 @@ async function runWatch(options) {
|
||||
|
||||
await sleep(normalizedOptions.watchIntervalSeconds * 1000);
|
||||
}
|
||||
|
||||
return exitCode;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
@@ -633,11 +785,19 @@ async function main() {
|
||||
}
|
||||
|
||||
if (options.watch) {
|
||||
await runWatch(options);
|
||||
const exitCode = await runWatch(options);
|
||||
if (options.exitCode) {
|
||||
process.exitCode = exitCode;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
writeStatus(buildStatus(options), options);
|
||||
const payload = buildStatus(options);
|
||||
tryWriteStatusSnapshots(payload, options);
|
||||
writeStatus(payload, options);
|
||||
if (options.exitCode) {
|
||||
process.exitCode = getStatusExitCode(payload);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
@@ -652,6 +812,9 @@ module.exports = {
|
||||
buildStatus,
|
||||
extractToolResultIds,
|
||||
extractToolUses,
|
||||
getStatusExitCode,
|
||||
parseArgs,
|
||||
runWatch,
|
||||
tryWriteStatusSnapshots,
|
||||
writeStatusSnapshots,
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ An interactive, step-by-step installation wizard for the Everything Claude Code
|
||||
## Prerequisites
|
||||
|
||||
This skill must be accessible to Claude Code before activation. Two ways to bootstrap:
|
||||
1. **Via Plugin**: `/plugin install everything-claude-code` — the plugin loads this skill automatically
|
||||
1. **Via Plugin**: `/plugin install ecc@ecc` — the plugin loads this skill automatically
|
||||
2. **Manual**: Copy only this skill to `~/.claude/skills/configure-ecc/SKILL.md`, then activate by saying "configure ecc"
|
||||
|
||||
---
|
||||
|
||||
@@ -26,7 +26,7 @@ An advanced learning system that turns your Claude Code sessions into reusable k
|
||||
|
||||
| Feature | v2.0 | v2.1 |
|
||||
|---------|------|------|
|
||||
| Storage | Global (~/.claude/homunculus/) | Project-scoped (projects/<hash>/) |
|
||||
| Storage | Global (`~/.claude/homunculus/`) | Project-scoped (`${XDG_DATA_HOME:-~/.local/share}/ecc-homunculus/projects/<hash>/`) |
|
||||
| Scope | All instincts apply everywhere | Project-scoped + global |
|
||||
| Detection | None | git remote URL / repo path |
|
||||
| Promotion | N/A | Project → global when seen in 2+ projects |
|
||||
@@ -132,7 +132,21 @@ The system automatically detects your current project:
|
||||
3. **`git rev-parse --show-toplevel`** -- fallback using repo path (machine-specific)
|
||||
4. **Global fallback** -- if no project is detected, instincts go to global scope
|
||||
|
||||
Each project gets a 12-character hash ID (e.g., `a1b2c3d4e5f6`). A registry file at `~/.claude/homunculus/projects.json` maps IDs to human-readable names.
|
||||
Each project gets a 12-character hash ID (e.g., `a1b2c3d4e5f6`). A registry file at `${XDG_DATA_HOME:-~/.local/share}/ecc-homunculus/projects.json` maps IDs to human-readable names.
|
||||
|
||||
### Data Directory
|
||||
|
||||
Continuous-learning-v2 stores observer data outside `~/.claude` so Claude Code's sensitive-path guard does not block background instinct writes:
|
||||
|
||||
1. `CLV2_HOMUNCULUS_DIR` when set to an absolute path
|
||||
2. `$XDG_DATA_HOME/ecc-homunculus`
|
||||
3. `$HOME/.local/share/ecc-homunculus`
|
||||
|
||||
Existing users with data at `~/.claude/homunculus` can migrate once:
|
||||
|
||||
```bash
|
||||
bash skills/continuous-learning-v2/scripts/migrate-homunculus.sh
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -173,7 +187,7 @@ The system creates directories automatically on first use, but you can also crea
|
||||
|
||||
```bash
|
||||
# Global directories
|
||||
mkdir -p ~/.claude/homunculus/{instincts/{personal,inherited},evolved/{agents,skills,commands},projects}
|
||||
mkdir -p "${XDG_DATA_HOME:-$HOME/.local/share}/ecc-homunculus"/{instincts/{personal,inherited},evolved/{agents,skills,commands},projects}
|
||||
|
||||
# Project directories are auto-created when the hook first runs in a git repo
|
||||
```
|
||||
@@ -226,7 +240,7 @@ Other behavior (observation capture, instinct thresholds, project scoping, promo
|
||||
## File Structure
|
||||
|
||||
```
|
||||
~/.claude/homunculus/
|
||||
${XDG_DATA_HOME:-~/.local/share}/ecc-homunculus/
|
||||
+-- identity.json # Your profile, technical level
|
||||
+-- projects.json # Registry: project hash -> name/path/remote
|
||||
+-- observations.jsonl # Global observations (fallback)
|
||||
@@ -322,7 +336,7 @@ Hooks fire **100% of the time**, deterministically. This means:
|
||||
## Backward Compatibility
|
||||
|
||||
v2.1 is fully compatible with v2.0 and v1:
|
||||
- Existing global instincts in `~/.claude/homunculus/instincts/` still work as global instincts
|
||||
- Existing global instincts can be migrated from `~/.claude/homunculus/instincts/` with `scripts/migrate-homunculus.sh`
|
||||
- Existing `~/.claude/skills/learned/` skills from v1 still work
|
||||
- Stop hook still runs (but now also feeds into v2)
|
||||
- Gradual migration: run both in parallel
|
||||
|
||||
@@ -10,6 +10,7 @@ unset CLAUDECODE
|
||||
|
||||
SLEEP_PID=""
|
||||
USR1_FIRED=0
|
||||
PENDING_ANALYSIS=0
|
||||
ANALYZING=0
|
||||
LAST_ANALYSIS_EPOCH=0
|
||||
# Minimum seconds between analyses (prevents rapid re-triggering)
|
||||
@@ -258,14 +259,17 @@ PROMPT
|
||||
on_usr1() {
|
||||
[ -n "$SLEEP_PID" ] && kill "$SLEEP_PID" 2>/dev/null
|
||||
SLEEP_PID=""
|
||||
USR1_FIRED=1
|
||||
|
||||
# Re-entrancy guard: skip if analysis is already running (#521)
|
||||
# Re-entrancy guard: defer the nudge so the main loop runs a follow-up
|
||||
# analysis immediately after the current analysis finishes.
|
||||
if [ "$ANALYZING" -eq 1 ]; then
|
||||
echo "[$(date)] Analysis already in progress, skipping signal" >> "$LOG_FILE"
|
||||
PENDING_ANALYSIS=1
|
||||
echo "[$(date)] Analysis already in progress, deferring signal" >> "$LOG_FILE"
|
||||
return
|
||||
fi
|
||||
|
||||
USR1_FIRED=1
|
||||
|
||||
# Cooldown: skip if last analysis was too recent (#521)
|
||||
now_epoch=$(date +%s)
|
||||
elapsed=$(( now_epoch - LAST_ANALYSIS_EPOCH ))
|
||||
@@ -290,6 +294,17 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
while true; do
|
||||
exit_if_idle_without_sessions
|
||||
|
||||
if [ "$PENDING_ANALYSIS" -eq 1 ]; then
|
||||
PENDING_ANALYSIS=0
|
||||
USR1_FIRED=0
|
||||
ANALYZING=1
|
||||
analyze_observations
|
||||
LAST_ANALYSIS_EPOCH=$(date +%s)
|
||||
ANALYZING=0
|
||||
continue
|
||||
fi
|
||||
|
||||
sleep "$OBSERVER_INTERVAL_SECONDS" &
|
||||
SLEEP_PID=$!
|
||||
wait "$SLEEP_PID" 2>/dev/null
|
||||
@@ -299,6 +314,9 @@ while true; do
|
||||
if [ "$USR1_FIRED" -eq 1 ]; then
|
||||
USR1_FIRED=0
|
||||
else
|
||||
ANALYZING=1
|
||||
analyze_observations
|
||||
LAST_ANALYSIS_EPOCH=$(date +%s)
|
||||
ANALYZING=0
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -17,8 +17,8 @@ A background agent that analyzes observations from Claude Code sessions to detec
|
||||
## Input
|
||||
|
||||
Reads observations from the **project-scoped** observations file:
|
||||
- Project: `~/.claude/homunculus/projects/<project-hash>/observations.jsonl`
|
||||
- Global fallback: `~/.claude/homunculus/observations.jsonl`
|
||||
- Project: `${XDG_DATA_HOME:-~/.local/share}/ecc-homunculus/projects/<project-hash>/observations.jsonl`
|
||||
- Global fallback: `${XDG_DATA_HOME:-~/.local/share}/ecc-homunculus/observations.jsonl`
|
||||
|
||||
```jsonl
|
||||
{"timestamp":"2025-01-22T10:30:00Z","event":"tool_start","session":"abc123","tool":"Edit","input":"...","project_id":"a1b2c3d4e5f6","project_name":"my-react-app"}
|
||||
@@ -66,8 +66,8 @@ When certain tools are consistently preferred:
|
||||
## Output
|
||||
|
||||
Creates/updates instincts in the **project-scoped** instincts directory:
|
||||
- Project: `~/.claude/homunculus/projects/<project-hash>/instincts/personal/`
|
||||
- Global: `~/.claude/homunculus/instincts/personal/` (for universal patterns)
|
||||
- Project: `${XDG_DATA_HOME:-~/.local/share}/ecc-homunculus/projects/<project-hash>/instincts/personal/`
|
||||
- Global: `${XDG_DATA_HOME:-~/.local/share}/ecc-homunculus/instincts/personal/` (for universal patterns)
|
||||
|
||||
### Project-Scoped Instinct (default)
|
||||
|
||||
|
||||
@@ -35,9 +35,13 @@ PYTHON_CMD="${CLV2_PYTHON_CMD:-}"
|
||||
# Configuration
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
CONFIG_DIR="${HOME}/.claude/homunculus"
|
||||
# shellcheck disable=SC1091
|
||||
. "${SKILL_ROOT}/scripts/lib/homunculus-dir.sh"
|
||||
CONFIG_DIR="$(_ecc_resolve_homunculus_dir)"
|
||||
if [ -n "${CLV2_CONFIG:-}" ]; then
|
||||
CONFIG_FILE="$CLV2_CONFIG"
|
||||
elif [ -f "${CONFIG_DIR}/config.json" ]; then
|
||||
CONFIG_FILE="${CONFIG_DIR}/config.json"
|
||||
else
|
||||
CONFIG_FILE="${SKILL_ROOT}/config.json"
|
||||
fi
|
||||
|
||||
@@ -115,7 +115,9 @@ fi
|
||||
# Sourcing detect-project.sh creates project-scoped directories and updates
|
||||
# projects.json, so automated sessions must return before that point.
|
||||
|
||||
CONFIG_DIR="${HOME}/.claude/homunculus"
|
||||
# shellcheck disable=SC1091
|
||||
. "$(dirname "$0")/../scripts/lib/homunculus-dir.sh"
|
||||
CONFIG_DIR="$(_ecc_resolve_homunculus_dir)"
|
||||
|
||||
# Skip if disabled (check both default and CLV2_CONFIG-derived locations)
|
||||
if [ -f "$CONFIG_DIR/disabled" ]; then
|
||||
@@ -344,10 +346,12 @@ if [ -f "${CONFIG_DIR}/disabled" ]; then
|
||||
OBSERVER_ENABLED=false
|
||||
else
|
||||
OBSERVER_ENABLED=false
|
||||
CONFIG_FILE="${SKILL_ROOT}/config.json"
|
||||
# Allow CLV2_CONFIG override
|
||||
if [ -n "${CLV2_CONFIG:-}" ]; then
|
||||
CONFIG_FILE="$CLV2_CONFIG"
|
||||
elif [ -f "${CONFIG_DIR}/config.json" ]; then
|
||||
CONFIG_FILE="${CONFIG_DIR}/config.json"
|
||||
else
|
||||
CONFIG_FILE="${SKILL_ROOT}/config.json"
|
||||
fi
|
||||
# Use effective config path for both existence check and reading
|
||||
EFFECTIVE_CONFIG="$CONFIG_FILE"
|
||||
|
||||
@@ -19,7 +19,9 @@
|
||||
# 3. git repo root path (fallback, machine-specific)
|
||||
# 4. "global" (no project context detected)
|
||||
|
||||
_CLV2_HOMUNCULUS_DIR="${HOME}/.claude/homunculus"
|
||||
# shellcheck disable=SC1091
|
||||
. "$(dirname "${BASH_SOURCE[0]}")/lib/homunculus-dir.sh"
|
||||
_CLV2_HOMUNCULUS_DIR="$(_ecc_resolve_homunculus_dir)"
|
||||
_CLV2_PROJECTS_DIR="${_CLV2_HOMUNCULUS_DIR}/projects"
|
||||
_CLV2_REGISTRY_FILE="${_CLV2_HOMUNCULUS_DIR}/projects.json"
|
||||
|
||||
@@ -49,6 +51,30 @@ export CLV2_PYTHON_CMD
|
||||
CLV2_OBSERVER_PROMPT_PATTERN='Can you confirm|requires permission|Awaiting (user confirmation|confirmation|approval|permission)|confirm I should proceed|once granted access|grant.*access'
|
||||
export CLV2_OBSERVER_PROMPT_PATTERN
|
||||
|
||||
_clv2_normalize_remote_url() {
|
||||
local url="$1"
|
||||
[ -z "$url" ] && return 0
|
||||
|
||||
local is_network=0
|
||||
case "$url" in
|
||||
file://*) is_network=0 ;;
|
||||
*://*) is_network=1 ;;
|
||||
*@*:*) is_network=1 ;;
|
||||
*) is_network=0 ;;
|
||||
esac
|
||||
|
||||
url=$(printf '%s' "$url" | sed -E 's|://[^@]+@|://|')
|
||||
url=$(printf '%s' "$url" | sed -E 's|^[A-Za-z][A-Za-z0-9+.-]*://||')
|
||||
url=$(printf '%s' "$url" | sed -E 's|^[^@/:]+@([^:/]+):|\1/|')
|
||||
url=$(printf '%s' "$url" | sed -E 's|\.git/?$||; s|/+$||')
|
||||
|
||||
if [ "$is_network" = "1" ]; then
|
||||
printf '%s' "$url" | tr '[:upper:]' '[:lower:]'
|
||||
else
|
||||
printf '%s' "$url"
|
||||
fi
|
||||
}
|
||||
|
||||
_clv2_detect_project() {
|
||||
local project_root=""
|
||||
local project_name=""
|
||||
@@ -94,15 +120,20 @@ _clv2_detect_project() {
|
||||
fi
|
||||
fi
|
||||
|
||||
# Compute hash from the original remote URL (legacy, for backward compatibility)
|
||||
local legacy_hash_input="${remote_url:-$project_root}"
|
||||
local raw_remote_url="$remote_url"
|
||||
|
||||
# Strip embedded credentials from remote URL (e.g., https://ghp_xxxx@github.com/...)
|
||||
if [ -n "$remote_url" ]; then
|
||||
remote_url=$(printf '%s' "$remote_url" | sed -E 's|://[^@]+@|://|')
|
||||
fi
|
||||
|
||||
local hash_input="${remote_url:-$project_root}"
|
||||
local legacy_hash_input="${remote_url:-$project_root}"
|
||||
local normalized_remote=""
|
||||
if [ -n "$remote_url" ]; then
|
||||
normalized_remote=$(_clv2_normalize_remote_url "$remote_url")
|
||||
fi
|
||||
|
||||
local hash_input="${normalized_remote:-${remote_url:-$project_root}}"
|
||||
# Prefer Python for consistent SHA256 behavior across shells/platforms.
|
||||
# Pass the value via env var and encode as UTF-8 inside Python so the hash
|
||||
# is locale-independent (shells vary between UTF-8 / CP932 / CP1252, which
|
||||
@@ -122,19 +153,33 @@ print(hashlib.sha256(s.encode("utf-8")).hexdigest()[:12])
|
||||
echo "fallback")
|
||||
fi
|
||||
|
||||
# Backward compatibility: if credentials were stripped and the hash changed,
|
||||
# check if a project dir exists under the legacy hash and reuse it
|
||||
if [ "$legacy_hash_input" != "$hash_input" ] && [ -n "$_CLV2_PYTHON_CMD" ]; then
|
||||
local legacy_id=""
|
||||
legacy_id=$(_CLV2_HASH_INPUT="$legacy_hash_input" "$_CLV2_PYTHON_CMD" -c '
|
||||
# Backward compatibility: migrate a single legacy project directory from
|
||||
# credential-stripped or raw remote hashes to the normalized remote hash.
|
||||
if [ -n "$_CLV2_PYTHON_CMD" ] && [ ! -d "${_CLV2_PROJECTS_DIR}/${project_id}" ]; then
|
||||
local legacy_inputs=()
|
||||
[ -n "$legacy_hash_input" ] && [ "$legacy_hash_input" != "$hash_input" ] \
|
||||
&& legacy_inputs+=("$legacy_hash_input")
|
||||
[ -n "$raw_remote_url" ] && [ "$raw_remote_url" != "$hash_input" ] \
|
||||
&& [ "$raw_remote_url" != "$legacy_hash_input" ] \
|
||||
&& legacy_inputs+=("$raw_remote_url")
|
||||
|
||||
local legacy_input legacy_id
|
||||
for legacy_input in "${legacy_inputs[@]}"; do
|
||||
legacy_id=$(_CLV2_HASH_INPUT="$legacy_input" "$_CLV2_PYTHON_CMD" -c '
|
||||
import os, hashlib
|
||||
s = os.environ["_CLV2_HASH_INPUT"]
|
||||
print(hashlib.sha256(s.encode("utf-8")).hexdigest()[:12])
|
||||
' 2>/dev/null)
|
||||
if [ -n "$legacy_id" ] && [ -d "${_CLV2_PROJECTS_DIR}/${legacy_id}" ] && [ ! -d "${_CLV2_PROJECTS_DIR}/${project_id}" ]; then
|
||||
# Migrate legacy directory to new hash
|
||||
mv "${_CLV2_PROJECTS_DIR}/${legacy_id}" "${_CLV2_PROJECTS_DIR}/${project_id}" 2>/dev/null || project_id="$legacy_id"
|
||||
fi
|
||||
if [ -n "$legacy_id" ] && [ "$legacy_id" != "$project_id" ] \
|
||||
&& [ -d "${_CLV2_PROJECTS_DIR}/${legacy_id}" ]; then
|
||||
if mv "${_CLV2_PROJECTS_DIR}/${legacy_id}" "${_CLV2_PROJECTS_DIR}/${project_id}" 2>/dev/null; then
|
||||
break
|
||||
else
|
||||
project_id="$legacy_id"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Export results
|
||||
|
||||
@@ -38,7 +38,48 @@ except ImportError:
|
||||
# Configuration
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
HOMUNCULUS_DIR = Path.home() / ".claude" / "homunculus"
|
||||
def _resolve_homunculus_dir() -> Path:
|
||||
override = os.environ.get("CLV2_HOMUNCULUS_DIR")
|
||||
if override:
|
||||
if Path(override).is_absolute():
|
||||
return Path(override)
|
||||
print(f"[ecc] CLV2_HOMUNCULUS_DIR={override!r} is not absolute; ignoring", file=sys.stderr)
|
||||
|
||||
xdg = os.environ.get("XDG_DATA_HOME")
|
||||
if xdg:
|
||||
if Path(xdg).is_absolute():
|
||||
return Path(xdg) / "ecc-homunculus"
|
||||
print(f"[ecc] XDG_DATA_HOME={xdg!r} is not absolute; ignoring", file=sys.stderr)
|
||||
|
||||
return Path.home() / ".local" / "share" / "ecc-homunculus"
|
||||
|
||||
|
||||
def _strip_remote_credentials(remote_url: str) -> str:
|
||||
return re.sub(r"://[^@]+@", "://", remote_url or "")
|
||||
|
||||
|
||||
def _normalize_remote_url(remote_url: str) -> str:
|
||||
if not remote_url:
|
||||
return ""
|
||||
|
||||
is_network = (
|
||||
not remote_url.startswith("file://")
|
||||
and ("://" in remote_url or re.match(r"^[^@/:]+@[^:/]+:", remote_url) is not None)
|
||||
)
|
||||
normalized = _strip_remote_credentials(remote_url)
|
||||
normalized = re.sub(r"^[A-Za-z][A-Za-z0-9+.-]*://", "", normalized)
|
||||
normalized = re.sub(r"^[^@/:]+@([^:/]+):", r"\1/", normalized)
|
||||
normalized = re.sub(r"\.git/?$", "", normalized)
|
||||
normalized = re.sub(r"/+$", "", normalized)
|
||||
|
||||
return normalized.lower() if is_network else normalized
|
||||
|
||||
|
||||
def _project_hash(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:12]
|
||||
|
||||
|
||||
HOMUNCULUS_DIR = _resolve_homunculus_dir()
|
||||
PROJECTS_DIR = HOMUNCULUS_DIR / "projects"
|
||||
REGISTRY_FILE = HOMUNCULUS_DIR / "projects.json"
|
||||
|
||||
@@ -177,11 +218,35 @@ def detect_project() -> dict:
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
hash_source = remote_url if remote_url else project_root
|
||||
project_id = hashlib.sha256(hash_source.encode()).hexdigest()[:12]
|
||||
raw_remote_url = remote_url
|
||||
if remote_url:
|
||||
remote_url = _strip_remote_credentials(remote_url)
|
||||
|
||||
legacy_hash_source = remote_url if remote_url else project_root
|
||||
normalized_remote = _normalize_remote_url(remote_url) if remote_url else ""
|
||||
hash_source = normalized_remote if normalized_remote else legacy_hash_source
|
||||
project_id = _project_hash(hash_source)
|
||||
|
||||
project_dir = PROJECTS_DIR / project_id
|
||||
|
||||
if not project_dir.exists():
|
||||
legacy_sources = []
|
||||
if legacy_hash_source and legacy_hash_source != hash_source:
|
||||
legacy_sources.append(legacy_hash_source)
|
||||
if raw_remote_url and raw_remote_url not in {hash_source, legacy_hash_source}:
|
||||
legacy_sources.append(raw_remote_url)
|
||||
|
||||
for legacy_source in legacy_sources:
|
||||
legacy_id = _project_hash(legacy_source)
|
||||
legacy_dir = PROJECTS_DIR / legacy_id
|
||||
if legacy_id != project_id and legacy_dir.exists():
|
||||
try:
|
||||
legacy_dir.rename(project_dir)
|
||||
except OSError:
|
||||
project_id = legacy_id
|
||||
project_dir = legacy_dir
|
||||
break
|
||||
|
||||
# Ensure project directory structure
|
||||
for d in [
|
||||
project_dir / "instincts" / "personal",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared continuous-learning-v2 data-directory resolver.
|
||||
#
|
||||
# Resolution precedence:
|
||||
# 1. CLV2_HOMUNCULUS_DIR, when absolute
|
||||
# 2. XDG_DATA_HOME/ecc-homunculus, when XDG_DATA_HOME is absolute
|
||||
# 3. HOME/.local/share/ecc-homunculus
|
||||
|
||||
_ecc_resolve_homunculus_dir() {
|
||||
if [ -n "${CLV2_HOMUNCULUS_DIR:-}" ]; then
|
||||
case "$CLV2_HOMUNCULUS_DIR" in
|
||||
/*) printf '%s\n' "$CLV2_HOMUNCULUS_DIR"; return 0 ;;
|
||||
*) printf '[ecc] CLV2_HOMUNCULUS_DIR=%s is not absolute; ignoring\n' "$CLV2_HOMUNCULUS_DIR" >&2 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if [ -n "${XDG_DATA_HOME:-}" ]; then
|
||||
case "$XDG_DATA_HOME" in
|
||||
/*) printf '%s/ecc-homunculus\n' "$XDG_DATA_HOME"; return 0 ;;
|
||||
*) printf '[ecc] XDG_DATA_HOME=%s is not absolute; ignoring\n' "$XDG_DATA_HOME" >&2 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
case "${HOME:-}" in
|
||||
/*) printf '%s/.local/share/ecc-homunculus\n' "$HOME" ;;
|
||||
*)
|
||||
printf '[ecc] HOME=%s is not absolute; cannot resolve homunculus dir\n' "${HOME:-}" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-shot migration from the legacy Claude config tree into the
|
||||
# continuous-learning-v2 data directory.
|
||||
set -euo pipefail
|
||||
|
||||
OLD="${HOME}/.claude/homunculus"
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
. "$(dirname "$0")/lib/homunculus-dir.sh"
|
||||
NEW="$(_ecc_resolve_homunculus_dir)"
|
||||
|
||||
if [ "$NEW" = "$OLD" ]; then
|
||||
echo "Resolved destination equals source ($OLD); nothing to migrate."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -d "$OLD" ]; then
|
||||
echo "Nothing to migrate (no $OLD)."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if command -v pgrep >/dev/null 2>&1; then
|
||||
if pgrep -f "${HOME}.*observer-loop\\.sh" >/dev/null 2>&1; then
|
||||
echo "Refusing to migrate: observer-loop.sh is running." >&2
|
||||
echo "Exit all Claude Code sessions, then re-run." >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Warning: pgrep not available; skipping running-observer check." >&2
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$NEW")"
|
||||
|
||||
if [ ! -d "$NEW" ]; then
|
||||
mv "$OLD" "$NEW"
|
||||
echo "Moved $OLD -> $NEW"
|
||||
elif [ -z "$(ls -A "$NEW" 2>/dev/null || true)" ]; then
|
||||
rmdir "$NEW"
|
||||
mv "$OLD" "$NEW"
|
||||
echo "Moved $OLD -> $NEW (replaced empty destination)"
|
||||
else
|
||||
old_count="$(find "$OLD" -type f 2>/dev/null | wc -l | tr -d ' ')"
|
||||
new_count="$(find "$NEW" -type f 2>/dev/null | wc -l | tr -d ' ')"
|
||||
echo "Refusing to migrate: both paths exist with content." >&2
|
||||
echo " Old: $OLD ($old_count files)" >&2
|
||||
echo " New: $NEW ($new_count files)" >&2
|
||||
echo "Resolve manually, then re-run." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
settings="${HOME}/.claude/settings.json"
|
||||
if [ -f "$settings" ] && grep -q '"CLV2_CONFIG"' "$settings" 2>/dev/null; then
|
||||
if grep -q '\.claude/homunculus' "$settings" 2>/dev/null; then
|
||||
cat >&2 <<WARN
|
||||
|
||||
Advisory: ~/.claude/settings.json still sets CLV2_CONFIG under the old path.
|
||||
Update it to: ${NEW}/config.json
|
||||
(Not editing settings.json automatically.)
|
||||
|
||||
WARN
|
||||
fi
|
||||
fi
|
||||
@@ -1,10 +1,18 @@
|
||||
---
|
||||
name: continuous-learning
|
||||
description: Automatically extract reusable patterns from Claude Code sessions and save them as learned skills for future use.
|
||||
description: "[DEPRECATED - use continuous-learning-v2] Legacy v1 stop-hook skill extractor. v2 is a strict superset with instinct-based, project-scoped, hook-reliable learning. Do not invoke v1; route continuous learning, session learning, and pattern extraction requests to continuous-learning-v2."
|
||||
origin: ECC
|
||||
---
|
||||
|
||||
# Continuous Learning Skill
|
||||
# Continuous Learning Skill - DEPRECATED
|
||||
|
||||
> **DEPRECATED 2026-04-28.** Use `continuous-learning-v2` instead. v2 is a strict superset: stop-hook observation becomes PreToolUse/PostToolUse observation, full skills become atomic instincts with confidence scoring, and global-only storage becomes project-scoped plus global promotion.
|
||||
>
|
||||
> This file is kept for archival reference and backward compatibility with existing installs.
|
||||
|
||||
---
|
||||
|
||||
## Original v1 Documentation (archival)
|
||||
|
||||
Automatically evaluates Claude Code sessions on end to extract reusable patterns that can be saved as learned skills.
|
||||
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
---
|
||||
name: flox-environments
|
||||
description: "Create reproducible, cross-platform development environments with Flox — a declarative environment manager built on Nix. ALWAYS use this skill when the user needs to: set up a project with system-level dependencies (compilers, databases, native libraries like openssl, libvips, BLAS, LAPACK); configure reproducible toolchains for Python, Node.js, Rust, Go, C/C++, Java, Ruby, Elixir, PHP, or any language; manage environments that must work identically across macOS and Linux; pin exact package versions for a team; run local services (PostgreSQL, Redis, Kafka) alongside development tools; onboard new developers with a single command; or solve 'works on my machine' problems. Especially valuable for AI-assisted and vibe coding — Flox lets agents install tools into a project-scoped environment without sudo, system pollution, or sandbox restrictions, and the resulting environment is committed to the repo so anyone can reproduce it instantly. Use this skill even if the user doesn't mention Flox — if they describe needing reproducible, declarative, cross-platform dev environments with system packages, this is the right tool. Also use when the user mentions .flox/, manifest.toml, flox activate, or FloxHub."
|
||||
origin: Flox
|
||||
---
|
||||
|
||||
# Flox Environments
|
||||
|
||||
Flox creates reproducible development environments defined in a single TOML manifest. Every developer on the team gets identical packages, tools, and configuration — across macOS and Linux — without containers or VMs. Built on Nix with access to over 150,000 packages.
|
||||
|
||||
## When to Activate
|
||||
|
||||
Use this skill when the user has an environment management problem — even if they haven't mentioned Flox. Flox is the right tool when:
|
||||
|
||||
- The project needs **system-level packages** (compilers, databases, CLI tools) alongside language-specific dependencies
|
||||
- **Reproducibility matters** — the setup should work identically on a teammate's machine, in CI, or on a fresh laptop
|
||||
- The user needs **multiple tools to coexist** — e.g., Python 3.11 + PostgreSQL 16 + Redis + Node.js in one environment
|
||||
- **Cross-platform support** is needed (macOS and Linux from the same config)
|
||||
- **AI agents need to install tools** — Flox lets agents add packages to a project-scoped environment without sudo, system pollution, or sandbox restrictions
|
||||
|
||||
If the user just needs a single language runtime with no system dependencies, standard tooling (nvm, pyenv, rustup alone) may suffice. If they need full OS-level isolation, containers might be more appropriate. Flox sits in the sweet spot: declarative, reproducible environments without container overhead.
|
||||
|
||||
**Prerequisite:** Flox must be installed first — see [flox.dev/docs](https://flox.dev/docs/install-flox/install/) for macOS, Linux, and Docker.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
Flox environments are defined in `.flox/env/manifest.toml` and activated with `flox activate`. The manifest declares packages, environment variables, setup hooks, and shell configuration — everything needed to reproduce the environment anywhere.
|
||||
|
||||
**Key paths:**
|
||||
- `.flox/env/manifest.toml` — Environment definition (commit this)
|
||||
- `$FLOX_ENV` — Runtime path to installed packages (like `/usr` — contains `bin/`, `lib/`, `include/`)
|
||||
- `$FLOX_ENV_CACHE` — Persistent local storage for caches, venvs, data (survives rebuilds)
|
||||
- `$FLOX_ENV_PROJECT` — Project root directory (where `.flox/` lives)
|
||||
|
||||
## Essential Commands
|
||||
|
||||
```bash
|
||||
flox init # Create new environment
|
||||
flox search <package> [--all] # Search for packages
|
||||
flox show <package> # Show available versions
|
||||
flox install <package> # Add a package
|
||||
flox list # List installed packages
|
||||
flox activate # Enter environment
|
||||
flox activate -- <cmd> # Run a command in the environment without a subshell
|
||||
flox edit # Edit manifest interactively
|
||||
```
|
||||
|
||||
## Manifest Structure
|
||||
|
||||
```toml
|
||||
# .flox/env/manifest.toml
|
||||
|
||||
[install]
|
||||
# Packages to install — the core of the environment
|
||||
ripgrep.pkg-path = "ripgrep"
|
||||
jq.pkg-path = "jq"
|
||||
|
||||
[vars]
|
||||
# Static environment variables
|
||||
DATABASE_URL = "postgres://localhost:5432/myapp"
|
||||
|
||||
[hook]
|
||||
# Non-interactive setup scripts (run every activation)
|
||||
on-activate = """
|
||||
echo "Environment ready"
|
||||
"""
|
||||
|
||||
[profile]
|
||||
# Shell functions and aliases (available in interactive shell)
|
||||
common = """
|
||||
alias dev="npm run dev"
|
||||
"""
|
||||
|
||||
[options]
|
||||
# Supported platforms
|
||||
systems = ["x86_64-linux", "aarch64-linux", "x86_64-darwin", "aarch64-darwin"]
|
||||
```
|
||||
|
||||
## Package Installation Patterns
|
||||
|
||||
### Basic Installation
|
||||
|
||||
```toml
|
||||
[install]
|
||||
nodejs.pkg-path = "nodejs"
|
||||
python.pkg-path = "python311"
|
||||
rustup.pkg-path = "rustup"
|
||||
```
|
||||
|
||||
### Version Pinning
|
||||
|
||||
```toml
|
||||
[install]
|
||||
nodejs.pkg-path = "nodejs"
|
||||
nodejs.version = "^20.0" # Semver range: latest 20.x
|
||||
|
||||
postgres.pkg-path = "postgresql"
|
||||
postgres.version = "16.2" # Exact version
|
||||
```
|
||||
|
||||
### Platform-Specific Packages
|
||||
|
||||
```toml
|
||||
[install]
|
||||
# Linux-only tools
|
||||
valgrind.pkg-path = "valgrind"
|
||||
valgrind.systems = ["x86_64-linux", "aarch64-linux"]
|
||||
|
||||
# macOS frameworks
|
||||
Security.pkg-path = "darwin.apple_sdk.frameworks.Security"
|
||||
Security.systems = ["x86_64-darwin", "aarch64-darwin"]
|
||||
|
||||
# GNU tools on macOS (where BSD defaults differ)
|
||||
coreutils.pkg-path = "coreutils"
|
||||
coreutils.systems = ["x86_64-darwin", "aarch64-darwin"]
|
||||
```
|
||||
|
||||
### Resolving Package Conflicts
|
||||
|
||||
When two packages install the same binary, use `priority` (lower number wins):
|
||||
|
||||
```toml
|
||||
[install]
|
||||
gcc.pkg-path = "gcc12"
|
||||
gcc.priority = 3
|
||||
|
||||
clang.pkg-path = "clang_18"
|
||||
clang.priority = 5 # gcc wins file conflicts
|
||||
```
|
||||
|
||||
Use `pkg-group` to group packages that should resolve versions together:
|
||||
|
||||
```toml
|
||||
[install]
|
||||
python.pkg-path = "python311"
|
||||
python.pkg-group = "python-stack"
|
||||
|
||||
pip.pkg-path = "python311Packages.pip"
|
||||
pip.pkg-group = "python-stack" # Resolves together with python
|
||||
```
|
||||
|
||||
## Language-Specific Recipes
|
||||
|
||||
### Python with uv
|
||||
|
||||
```toml
|
||||
[install]
|
||||
python.pkg-path = "python311"
|
||||
uv.pkg-path = "uv"
|
||||
|
||||
[vars]
|
||||
UV_CACHE_DIR = "$FLOX_ENV_CACHE/uv-cache"
|
||||
PIP_CACHE_DIR = "$FLOX_ENV_CACHE/pip-cache"
|
||||
|
||||
[hook]
|
||||
on-activate = """
|
||||
venv="$FLOX_ENV_CACHE/venv"
|
||||
if [ ! -d "$venv" ]; then
|
||||
uv venv "$venv" --python python3
|
||||
fi
|
||||
if [ -f "$venv/bin/activate" ]; then
|
||||
source "$venv/bin/activate"
|
||||
fi
|
||||
|
||||
if [ -f requirements.txt ] && [ ! -f "$FLOX_ENV_CACHE/.deps_installed" ]; then
|
||||
uv pip install --python "$venv/bin/python" -r requirements.txt --quiet
|
||||
touch "$FLOX_ENV_CACHE/.deps_installed"
|
||||
fi
|
||||
"""
|
||||
```
|
||||
|
||||
### Node.js
|
||||
|
||||
```toml
|
||||
[install]
|
||||
nodejs.pkg-path = "nodejs"
|
||||
nodejs.version = "^20.0"
|
||||
|
||||
[hook]
|
||||
on-activate = """
|
||||
if [ -f package.json ] && [ ! -d node_modules ]; then
|
||||
npm install --silent
|
||||
fi
|
||||
"""
|
||||
```
|
||||
|
||||
### Rust
|
||||
|
||||
```toml
|
||||
[install]
|
||||
rustup.pkg-path = "rustup"
|
||||
pkg-config.pkg-path = "pkg-config"
|
||||
openssl.pkg-path = "openssl"
|
||||
|
||||
[vars]
|
||||
RUSTUP_HOME = "$FLOX_ENV_CACHE/rustup"
|
||||
CARGO_HOME = "$FLOX_ENV_CACHE/cargo"
|
||||
|
||||
[profile]
|
||||
common = """
|
||||
export PATH="$CARGO_HOME/bin:$PATH"
|
||||
"""
|
||||
```
|
||||
|
||||
### Go
|
||||
|
||||
```toml
|
||||
[install]
|
||||
go.pkg-path = "go"
|
||||
gopls.pkg-path = "gopls"
|
||||
delve.pkg-path = "delve"
|
||||
|
||||
[vars]
|
||||
GOPATH = "$FLOX_ENV_CACHE/go"
|
||||
GOBIN = "$FLOX_ENV_CACHE/go/bin"
|
||||
|
||||
[profile]
|
||||
common = """
|
||||
export PATH="$GOBIN:$PATH"
|
||||
"""
|
||||
```
|
||||
|
||||
### C/C++
|
||||
|
||||
```toml
|
||||
[install]
|
||||
gcc.pkg-path = "gcc13"
|
||||
gcc.pkg-group = "compilers"
|
||||
|
||||
# IMPORTANT: gcc alone doesn't expose libstdc++ headers — you need gcc-unwrapped
|
||||
gcc-unwrapped.pkg-path = "gcc-unwrapped"
|
||||
gcc-unwrapped.pkg-group = "libraries"
|
||||
|
||||
cmake.pkg-path = "cmake"
|
||||
cmake.pkg-group = "build"
|
||||
|
||||
gnumake.pkg-path = "gnumake"
|
||||
gnumake.pkg-group = "build"
|
||||
|
||||
gdb.pkg-path = "gdb"
|
||||
gdb.systems = ["x86_64-linux", "aarch64-linux"]
|
||||
```
|
||||
|
||||
## Hooks and Profile
|
||||
|
||||
### Hooks — Non-Interactive Setup
|
||||
|
||||
Hooks run on every activation. Keep them fast and idempotent. Rule of thumb: **if it should happen automatically, put it in `[hook]`; if the user should be able to type it, put it in `[profile]`.**
|
||||
|
||||
```toml
|
||||
[hook]
|
||||
on-activate = """
|
||||
setup_database() {
|
||||
if [ ! -d "$FLOX_ENV_CACHE/pgdata" ]; then
|
||||
initdb -D "$FLOX_ENV_CACHE/pgdata" --no-locale --encoding=UTF8
|
||||
fi
|
||||
}
|
||||
setup_database
|
||||
"""
|
||||
```
|
||||
|
||||
### Profile — Interactive Shell Configuration
|
||||
|
||||
Profile code is available in the user's shell session.
|
||||
|
||||
```toml
|
||||
[profile]
|
||||
common = """
|
||||
dev() { npm run dev; }
|
||||
test() { npm run test -- "$@"; }
|
||||
"""
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
### Absolute Paths
|
||||
|
||||
```toml
|
||||
# BAD — breaks on other machines
|
||||
[vars]
|
||||
PROJECT_DIR = "/home/alice/projects/myapp"
|
||||
|
||||
# GOOD — use Flox environment variables
|
||||
[vars]
|
||||
PROJECT_DIR = "$FLOX_ENV_PROJECT"
|
||||
```
|
||||
|
||||
### Using exit in Hooks
|
||||
|
||||
```toml
|
||||
# BAD — kills the shell
|
||||
[hook]
|
||||
on-activate = """
|
||||
if [ ! -f config.json ]; then
|
||||
echo "Missing config"
|
||||
exit 1
|
||||
fi
|
||||
"""
|
||||
|
||||
# GOOD — return from hook, don't exit
|
||||
[hook]
|
||||
on-activate = """
|
||||
if [ ! -f config.json ]; then
|
||||
echo "Missing config — run setup first"
|
||||
return 1
|
||||
fi
|
||||
"""
|
||||
```
|
||||
|
||||
### Storing Secrets in Manifest
|
||||
|
||||
```toml
|
||||
# BAD — manifest is committed to git
|
||||
[vars]
|
||||
API_KEY = "<set-at-runtime>"
|
||||
|
||||
# GOOD — reference external config or pass at runtime
|
||||
# Use: API_KEY="<your-api-key>" flox activate
|
||||
[vars]
|
||||
API_KEY = "${API_KEY:-}"
|
||||
```
|
||||
|
||||
### Slow Hooks Without Idempotency Guards
|
||||
|
||||
```toml
|
||||
# BAD — reinstalls every activation
|
||||
[hook]
|
||||
on-activate = """
|
||||
pip install -r requirements.txt
|
||||
"""
|
||||
|
||||
# GOOD — skip if already installed
|
||||
[hook]
|
||||
on-activate = """
|
||||
if [ ! -f "$FLOX_ENV_CACHE/.deps_installed" ]; then
|
||||
uv pip install -r requirements.txt --quiet
|
||||
touch "$FLOX_ENV_CACHE/.deps_installed"
|
||||
fi
|
||||
"""
|
||||
```
|
||||
|
||||
### Putting User Commands in Hooks
|
||||
|
||||
```toml
|
||||
# BAD — hook functions aren't available in the interactive shell
|
||||
[hook]
|
||||
on-activate = """
|
||||
deploy() { kubectl apply -f k8s/; }
|
||||
"""
|
||||
|
||||
# GOOD — use [profile] for user-invokable functions
|
||||
[profile]
|
||||
common = """
|
||||
deploy() { kubectl apply -f k8s/; }
|
||||
"""
|
||||
```
|
||||
|
||||
## Full-Stack Example
|
||||
|
||||
A complete environment for a Python API with PostgreSQL:
|
||||
|
||||
```toml
|
||||
[install]
|
||||
python.pkg-path = "python311"
|
||||
uv.pkg-path = "uv"
|
||||
postgresql.pkg-path = "postgresql_16"
|
||||
redis.pkg-path = "redis"
|
||||
jq.pkg-path = "jq"
|
||||
curl.pkg-path = "curl"
|
||||
|
||||
[vars]
|
||||
UV_CACHE_DIR = "$FLOX_ENV_CACHE/uv-cache"
|
||||
DATABASE_URL = "postgres://localhost:5432/myapp"
|
||||
REDIS_URL = "redis://localhost:6379"
|
||||
|
||||
[hook]
|
||||
on-activate = """
|
||||
if [ ! -d "$FLOX_ENV_CACHE/pgdata" ]; then
|
||||
initdb -D "$FLOX_ENV_CACHE/pgdata" --no-locale --encoding=UTF8
|
||||
fi
|
||||
|
||||
venv="$FLOX_ENV_CACHE/venv"
|
||||
if [ ! -d "$venv" ]; then
|
||||
uv venv "$venv" --python python3
|
||||
fi
|
||||
if [ -f "$venv/bin/activate" ]; then
|
||||
source "$venv/bin/activate"
|
||||
fi
|
||||
|
||||
if [ -f requirements.txt ] && [ ! -f "$FLOX_ENV_CACHE/.deps_installed" ]; then
|
||||
uv pip install --python "$venv/bin/python" -r requirements.txt --quiet
|
||||
touch "$FLOX_ENV_CACHE/.deps_installed"
|
||||
fi
|
||||
"""
|
||||
|
||||
[profile]
|
||||
common = """
|
||||
serve() { uvicorn app.main:app --reload --host 0.0.0.0 --port 8000; }
|
||||
migrate() { alembic upgrade head; }
|
||||
"""
|
||||
|
||||
[services]
|
||||
postgres.command = "postgres -D $FLOX_ENV_CACHE/pgdata -k $FLOX_ENV_CACHE"
|
||||
redis.command = "redis-server --port 6379 --daemonize no"
|
||||
|
||||
[options]
|
||||
systems = ["x86_64-linux", "aarch64-linux", "x86_64-darwin", "aarch64-darwin"]
|
||||
```
|
||||
|
||||
Activate with services: `flox activate --start-services`
|
||||
|
||||
## Environment Sharing
|
||||
|
||||
Flox environments are git-native. Commit the `.flox/` directory and every collaborator gets the same environment:
|
||||
|
||||
```bash
|
||||
git add .flox/
|
||||
git commit -m "Add Flox environment"
|
||||
# Teammates just run:
|
||||
git clone <repo> && cd <repo> && flox activate
|
||||
```
|
||||
|
||||
For reusable base environments across projects, push to FloxHub:
|
||||
|
||||
```bash
|
||||
flox push # Push environment to FloxHub
|
||||
flox activate -r owner/env-name # Activate remote environment anywhere
|
||||
```
|
||||
|
||||
Compose environments with `[include]`:
|
||||
|
||||
```toml
|
||||
[include]
|
||||
base.floxhub = "myorg/python-base"
|
||||
|
||||
[install]
|
||||
# Project-specific additions on top of base
|
||||
fastapi.pkg-path = "python311Packages.fastapi"
|
||||
```
|
||||
|
||||
## AI-Assisted and Vibe Coding
|
||||
|
||||
Flox is ideal for AI-assisted development and vibe coding workflows. When an AI agent needs a tool that isn't available in the current environment — a compiler, a database, a linter, a CLI utility — it can add it to the project's Flox manifest without requiring sudo access, polluting system packages, or hitting sandbox restrictions.
|
||||
|
||||
**Why this matters for agents:**
|
||||
- **No sudo required** — `flox install` works entirely in user space, so agents can add packages without elevated permissions
|
||||
- **Project-scoped** — packages are installed into the project environment only, not globally, so different projects can have different versions without conflict
|
||||
- **Sandbox-friendly** — agents running in sandboxed or restricted environments can still install the tools they need through Flox
|
||||
- **Reversible** — every change is captured in `manifest.toml`, so unwanted packages can be removed cleanly with no system residue
|
||||
- **Reproducible** — when an agent sets up an environment, that exact setup is committed to git and works for everyone
|
||||
|
||||
**Agent workflow pattern:**
|
||||
|
||||
```bash
|
||||
# Agent discovers it needs a tool (e.g., jq for JSON processing)
|
||||
flox search jq # Verify the package exists
|
||||
flox install jq # Install into project environment
|
||||
|
||||
# Or for more control, edit the manifest directly
|
||||
tmp_manifest="$(mktemp)"
|
||||
flox list -c > "$tmp_manifest"
|
||||
# Add the package to [install] section, then apply
|
||||
flox edit -f "$tmp_manifest"
|
||||
|
||||
# Run a command with the tool available
|
||||
flox activate -- jq '.results[]' data.json
|
||||
```
|
||||
|
||||
This makes Flox a natural fit for any workflow where Claude Code or other AI agents need to bootstrap project tooling on the fly.
|
||||
|
||||
## Debugging
|
||||
|
||||
```bash
|
||||
flox list -c # Show raw manifest
|
||||
flox activate -- which python # Check which binary resolves
|
||||
flox activate -- env | grep FLOX # See Flox environment variables
|
||||
flox search <package> --all # Broader package search (case-sensitive)
|
||||
```
|
||||
|
||||
**Common issues:**
|
||||
- **Package not found:** Search is case-sensitive — try `flox search --all`
|
||||
- **File conflicts between packages:** Add `priority` to the package that should win
|
||||
- **Hook failures:** Use `return` not `exit`; guard with `${FLOX_ENV_CACHE:-}`
|
||||
- **Stale dependencies:** Delete the `$FLOX_ENV_CACHE/.deps_installed` flag file
|
||||
|
||||
## Related Skills
|
||||
|
||||
The following skills are available as part of the [Flox Claude Code plugin](https://github.com/flox/flox-agentic) for deeper integration:
|
||||
|
||||
- **flox-services** — Service management, database setup, background processes
|
||||
- **flox-builds** — Reproducible builds and packaging with Flox
|
||||
- **flox-containers** — Create Docker/OCI containers from Flox environments
|
||||
- **flox-sharing** — Environment composition, remote environments, team patterns
|
||||
- **flox-cuda** — CUDA and GPU development environments
|
||||
|
||||
Learn more and install at [flox.dev/docs](https://flox.dev/docs/install-flox/install/)
|
||||
@@ -94,6 +94,10 @@ Triggers on: `rm -rf`, `git reset --hard`, `git push --force`, `drop table`, etc
|
||||
|
||||
The hook at `scripts/hooks/gateguard-fact-force.js` is included in this plugin. Enable it via hooks.json.
|
||||
|
||||
If GateGuard blocks setup or repair work, start the session with
|
||||
`ECC_GATEGUARD=off`. For hook-level control, keep using
|
||||
`ECC_DISABLED_HOOKS` with the GateGuard hook ID.
|
||||
|
||||
### Option B: Full package with config
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
---
|
||||
name: ios-icon-gen
|
||||
description: Generate iOS app icons as PNG imagesets for Xcode asset catalogs from SF Symbols (5000+ Apple-native) or Iconify API (275k+ open source icons from 200+ collections). Use when generating icons, creating icon assets, adding icons to asset catalog, or searching for icons for iOS projects.
|
||||
origin: community
|
||||
---
|
||||
|
||||
# iOS Icon Generator
|
||||
|
||||
Generate PNG icon imagesets for Xcode asset catalogs from two sources.
|
||||
|
||||
## When to Activate
|
||||
|
||||
- Generating icon assets for an iOS/macOS Xcode project
|
||||
- Searching for icons across open source collections
|
||||
- Creating PNG imagesets (1x, 2x, 3x) for asset catalogs
|
||||
- Replacing placeholder icons with production-quality assets
|
||||
- Matching existing icon styles in an Xcode project
|
||||
|
||||
## Core Principles
|
||||
|
||||
### 1. Two Sources, One Output Format
|
||||
Both sources produce identical Xcode-compatible imagesets. Choose based on need:
|
||||
|
||||
| Source | Icons | Requires | Best for |
|
||||
|--------|-------|----------|----------|
|
||||
| **Iconify API** | 275,000+ from 200+ collections | Internet | Wide selection, specific styles, open source icons |
|
||||
| **SF Symbols** | 5,000+ Apple symbols | macOS only | Apple-native style, offline use |
|
||||
|
||||
### 2. Always Match Existing Style
|
||||
Before generating, check the project's existing icons for size, color, and weight consistency.
|
||||
|
||||
### 3. Output Structure
|
||||
Both methods produce a complete Xcode imageset:
|
||||
|
||||
```
|
||||
<output-dir>/<asset-name>.imageset/
|
||||
Contents.json
|
||||
<asset-name>.png # 1x (68px default)
|
||||
<asset-name>@2x.png # 2x (136px default)
|
||||
<asset-name>@3x.png # 3x (204px default)
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Step 1: Assess Requirements
|
||||
|
||||
Determine icon needs: what the icon represents, preferred style, target color, and size.
|
||||
|
||||
If the project already has icons, check existing style:
|
||||
```bash
|
||||
# Check dimensions of existing icon
|
||||
sips -g pixelWidth -g pixelHeight path/to/existing@2x.png
|
||||
```
|
||||
|
||||
### Step 2: Search for Icons
|
||||
|
||||
**Iconify API (recommended for wide selection):**
|
||||
```bash
|
||||
# Search all collections
|
||||
$SKILL_DIR/scripts/iconify_gen.sh search "receipt"
|
||||
|
||||
# Search within a specific collection
|
||||
$SKILL_DIR/scripts/iconify_gen.sh search "business card" --prefix mdi
|
||||
|
||||
# List available collections
|
||||
$SKILL_DIR/scripts/iconify_gen.sh collections
|
||||
```
|
||||
|
||||
**SF Symbols (for Apple-native style):**
|
||||
Browse the SF Symbols app or reference common names:
|
||||
|
||||
| Use Case | Symbol Name |
|
||||
|----------|-------------|
|
||||
| Document | `doc.text`, `doc.fill` |
|
||||
| Receipt | `doc.text.below.ecg`, `receipt` |
|
||||
| Person | `person.crop.rectangle`, `person.text.rectangle` |
|
||||
| Camera | `camera`, `camera.fill` |
|
||||
| Scan | `doc.viewfinder`, `qrcode.viewfinder` |
|
||||
| Settings | `gearshape`, `slider.horizontal.3` |
|
||||
|
||||
### Step 3: Preview (Optional)
|
||||
|
||||
```bash
|
||||
# Iconify preview
|
||||
$SKILL_DIR/scripts/iconify_gen.sh preview mdi:receipt-text-outline
|
||||
```
|
||||
|
||||
### Step 4: Generate
|
||||
|
||||
**Iconify API:**
|
||||
```bash
|
||||
# Basic generation
|
||||
$SKILL_DIR/scripts/iconify_gen.sh mdi:receipt-text-outline editTool_expenseReport
|
||||
|
||||
# Custom color and output location
|
||||
$SKILL_DIR/scripts/iconify_gen.sh mdi:receipt-text-outline myIcon --color 007AFF --output ./Assets.xcassets/icons
|
||||
```
|
||||
|
||||
Options: `--size <pt>` (default: 68), `--color <hex>` (default: 8E8E93), `--output <dir>` (default: /tmp/icons)
|
||||
|
||||
**SF Symbols:**
|
||||
```bash
|
||||
# Basic generation
|
||||
swift $SKILL_DIR/scripts/generate_icons.swift doc.text.below.ecg editTool_expenseReport
|
||||
|
||||
# Custom color, weight, and output
|
||||
swift $SKILL_DIR/scripts/generate_icons.swift person.crop.rectangle myIcon --color 007AFF --weight regular --output ./Assets.xcassets/icons
|
||||
```
|
||||
|
||||
Options: `--size <pt>` (default: 68), `--color <hex>` (default: 8E8E93), `--weight <name>` (default: thin), `--output <dir>` (default: /tmp/icons)
|
||||
|
||||
### Step 5: Verify and Integrate
|
||||
|
||||
1. Read the generated @2x PNG to verify visually
|
||||
2. Copy to asset catalog if not output there directly:
|
||||
```bash
|
||||
cp -r /tmp/icons/<name>.imageset path/to/Assets.xcassets/<group>/
|
||||
```
|
||||
3. Build the project to verify Xcode picks up the new assets
|
||||
|
||||
## Popular Iconify Collections
|
||||
|
||||
| Prefix | Name | Count | Style |
|
||||
|--------|------|-------|-------|
|
||||
| `mdi` | Material Design Icons | 7400+ | Filled + outline variants |
|
||||
| `ph` | Phosphor | 9000+ | 6 weights per icon |
|
||||
| `solar` | Solar | 7400+ | Bold, linear, outline |
|
||||
| `tabler` | Tabler Icons | 6000+ | Consistent stroke width |
|
||||
| `lucide` | Lucide | 1700+ | Clean, minimal |
|
||||
| `ri` | Remix Icon | 3100+ | Filled + line variants |
|
||||
| `carbon` | Carbon | 2400+ | IBM design language |
|
||||
| `heroicons` | HeroIcons | 1200+ | Tailwind CSS companion |
|
||||
|
||||
Browse all: <https://icon-sets.iconify.design/>
|
||||
|
||||
## Scripts Reference
|
||||
|
||||
| Script | Source | Path |
|
||||
|--------|--------|------|
|
||||
| `iconify_gen.sh` | Iconify API (275k+ icons) | `$SKILL_DIR/scripts/iconify_gen.sh` |
|
||||
| `generate_icons.swift` | SF Symbols (5k+ icons) | `$SKILL_DIR/scripts/generate_icons.swift` |
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Search before generating** -- browse available icons to find the best match
|
||||
- **Match existing project style** -- check dimensions, color, and weight of existing icons before generating new ones
|
||||
- **Use Iconify for variety** -- 200+ collections means you can find the exact style you need
|
||||
- **Use SF Symbols for Apple consistency** -- they match system UI perfectly
|
||||
- **Generate directly to asset catalog** -- use `--output ./Assets.xcassets/icons` to skip manual copying
|
||||
- **Verify visually** -- always preview the @2x PNG before committing
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- Generating icons without checking existing project icon style
|
||||
- Using default colors when the project has a defined color palette
|
||||
- Generating at wrong sizes (check existing icons first)
|
||||
- Committing generated icons without visual verification
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
#!/usr/bin/env swift
|
||||
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
struct IconSpec {
|
||||
let symbolName: String
|
||||
let assetName: String
|
||||
let baseSize: CGFloat
|
||||
let color: NSColor
|
||||
let weight: NSFont.Weight
|
||||
}
|
||||
|
||||
func parseColor(_ hex: String) -> NSColor {
|
||||
var hex = hex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if hex.hasPrefix("#") { hex.removeFirst() }
|
||||
guard hex.count == 6, let value = UInt64(hex, radix: 16) else {
|
||||
return NSColor(red: 142/255, green: 142/255, blue: 147/255, alpha: 1.0)
|
||||
}
|
||||
return NSColor(
|
||||
red: CGFloat((value >> 16) & 0xFF) / 255,
|
||||
green: CGFloat((value >> 8) & 0xFF) / 255,
|
||||
blue: CGFloat(value & 0xFF) / 255,
|
||||
alpha: 1.0
|
||||
)
|
||||
}
|
||||
|
||||
func parseWeight(_ name: String) -> NSFont.Weight {
|
||||
switch name.lowercased() {
|
||||
case "ultralight": return .ultraLight
|
||||
case "thin": return .thin
|
||||
case "light": return .light
|
||||
case "regular": return .regular
|
||||
case "medium": return .medium
|
||||
case "semibold": return .semibold
|
||||
case "bold": return .bold
|
||||
case "heavy": return .heavy
|
||||
case "black": return .black
|
||||
default: return .thin
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Generation
|
||||
|
||||
enum IconError: Error, CustomStringConvertible {
|
||||
case directoryCreation(String)
|
||||
case symbolNotFound(String)
|
||||
case configurationFailed(String)
|
||||
case pngCreation(String)
|
||||
case fileWrite(String)
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .directoryCreation(let msg): return msg
|
||||
case .symbolNotFound(let msg): return msg
|
||||
case .configurationFailed(let msg): return msg
|
||||
case .pngCreation(let msg): return msg
|
||||
case .fileWrite(let msg): return msg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func generateIcon(_ spec: IconSpec, outputDir: String) throws {
|
||||
let dir = "\(outputDir)/\(spec.assetName).imageset"
|
||||
do {
|
||||
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
|
||||
} catch {
|
||||
throw IconError.directoryCreation("Could not create output directory '\(dir)': \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
let scales: [(suffix: String, multiplier: CGFloat)] = [("", 1), ("@2x", 2), ("@3x", 3)]
|
||||
|
||||
for scale in scales {
|
||||
let pixelSize = spec.baseSize * scale.multiplier
|
||||
let imageSize = NSSize(width: pixelSize, height: pixelSize)
|
||||
|
||||
let config = NSImage.SymbolConfiguration(
|
||||
pointSize: pixelSize * 0.40,
|
||||
weight: spec.weight,
|
||||
scale: .large
|
||||
)
|
||||
|
||||
guard let symbol = NSImage(systemSymbolName: spec.symbolName, accessibilityDescription: nil) else {
|
||||
throw IconError.symbolNotFound("SF Symbol '\(spec.symbolName)' not found. Run 'SF Symbols' app to browse available names.")
|
||||
}
|
||||
|
||||
guard let configured = symbol.withSymbolConfiguration(config) else {
|
||||
throw IconError.configurationFailed("Could not apply symbol configuration to '\(spec.symbolName)'")
|
||||
}
|
||||
|
||||
let image = NSImage(size: imageSize, flipped: false) { rect in
|
||||
let symSize = configured.size
|
||||
let x = (rect.width - symSize.width) / 2
|
||||
let y = (rect.height - symSize.height) / 2
|
||||
let drawRect = NSRect(x: x, y: y, width: symSize.width, height: symSize.height)
|
||||
|
||||
let tinted = NSImage(size: symSize, flipped: false) { tintRect in
|
||||
configured.draw(in: tintRect)
|
||||
spec.color.set()
|
||||
tintRect.fill(using: .sourceAtop)
|
||||
return true
|
||||
}
|
||||
|
||||
tinted.draw(in: drawRect, from: .zero, operation: .sourceOver, fraction: 1.0)
|
||||
return true
|
||||
}
|
||||
|
||||
guard let tiffData = image.tiffRepresentation,
|
||||
let bitmap = NSBitmapImageRep(data: tiffData),
|
||||
let pngData = bitmap.representation(using: .png, properties: [:]) else {
|
||||
throw IconError.pngCreation("Failed to create PNG for \(spec.assetName)\(scale.suffix)")
|
||||
}
|
||||
|
||||
let fileName = "\(spec.assetName)\(scale.suffix).png"
|
||||
do {
|
||||
try pngData.write(to: URL(fileURLWithPath: "\(dir)/\(fileName)"))
|
||||
} catch {
|
||||
throw IconError.fileWrite("Failed to write \(fileName): \(error.localizedDescription)")
|
||||
}
|
||||
print(" \(fileName) (\(Int(pixelSize))x\(Int(pixelSize)))")
|
||||
}
|
||||
|
||||
// Write Contents.json
|
||||
let json = """
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "\(spec.assetName).png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "\(spec.assetName)@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "\(spec.assetName)@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
"""
|
||||
do {
|
||||
try json.write(toFile: "\(dir)/Contents.json", atomically: true, encoding: .utf8)
|
||||
} catch {
|
||||
throw IconError.fileWrite("Failed to write Contents.json: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
func requireOptionValue(_ args: [String], at index: Int, flag: String) -> String {
|
||||
guard index < args.count else {
|
||||
fputs("ERROR: Missing value for \(flag)\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
let value = args[index]
|
||||
if value.hasPrefix("--") {
|
||||
fputs("ERROR: Missing value for \(flag)\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// MARK: - CLI
|
||||
|
||||
let args = CommandLine.arguments
|
||||
|
||||
if args.count < 3 || args.contains("--help") || args.contains("-h") {
|
||||
print("""
|
||||
Usage: generate_icons.swift <sf-symbol-name> <asset-name> [options]
|
||||
|
||||
Options:
|
||||
--size <pt> Base size in points (default: 68)
|
||||
--color <hex> Color hex code (default: 8E8E93)
|
||||
--weight <name> Font weight: ultralight|thin|light|regular|medium|semibold|bold|heavy|black (default: thin)
|
||||
--output <dir> Output directory (default: /tmp/icons)
|
||||
|
||||
Examples:
|
||||
generate_icons.swift doc.text.below.ecg editTool_expenseReport
|
||||
generate_icons.swift person.crop.rectangle editTool_businessCard --color 007AFF --weight regular
|
||||
generate_icons.swift receipt myReceipt --size 48 --output ./Assets.xcassets/icons
|
||||
|
||||
Browse SF Symbol names: open the SF Symbols app (free from Apple) or https://developer.apple.com/sf-symbols/
|
||||
""")
|
||||
exit(0)
|
||||
}
|
||||
|
||||
let symbolName = args[1]
|
||||
let assetName = args[2]
|
||||
|
||||
var baseSize: CGFloat = 68
|
||||
var colorHex = "8E8E93"
|
||||
var weightName = "thin"
|
||||
var outputDir = "/tmp/icons"
|
||||
|
||||
var i = 3
|
||||
while i < args.count {
|
||||
switch args[i] {
|
||||
case "--size":
|
||||
let raw = requireOptionValue(args, at: i + 1, flag: "--size")
|
||||
guard let size = Double(raw), size > 0 else {
|
||||
fputs("ERROR: --size must be a positive number\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
baseSize = CGFloat(size)
|
||||
i += 2
|
||||
continue
|
||||
case "--color":
|
||||
colorHex = requireOptionValue(args, at: i + 1, flag: "--color")
|
||||
let stripped = colorHex.hasPrefix("#") ? String(colorHex.dropFirst()) : colorHex
|
||||
guard stripped.count == 6, UInt64(stripped, radix: 16) != nil else {
|
||||
fputs("ERROR: --color must be a 6-digit hex code (e.g. 007AFF)\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
i += 2
|
||||
continue
|
||||
case "--weight":
|
||||
weightName = requireOptionValue(args, at: i + 1, flag: "--weight")
|
||||
let validWeights = ["ultralight", "thin", "light", "regular", "medium", "semibold", "bold", "heavy", "black"]
|
||||
guard validWeights.contains(weightName.lowercased()) else {
|
||||
fputs("ERROR: --weight must be one of: \(validWeights.joined(separator: ", "))\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
i += 2
|
||||
continue
|
||||
case "--output":
|
||||
outputDir = requireOptionValue(args, at: i + 1, flag: "--output")
|
||||
i += 2
|
||||
continue
|
||||
default:
|
||||
fputs("WARNING: Unknown option \(args[i])\n", stderr)
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
|
||||
let spec = IconSpec(
|
||||
symbolName: symbolName,
|
||||
assetName: assetName,
|
||||
baseSize: baseSize,
|
||||
color: parseColor(colorHex),
|
||||
weight: parseWeight(weightName)
|
||||
)
|
||||
|
||||
print("Generating \(assetName) from SF Symbol '\(symbolName)':")
|
||||
do {
|
||||
try generateIcon(spec, outputDir: outputDir)
|
||||
print("Output: \(outputDir)/\(assetName).imageset/")
|
||||
} catch {
|
||||
fputs("ERROR: \(error)\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
Executable
+235
@@ -0,0 +1,235 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Generate iOS icon imagesets from Iconify API (275k+ open source icons)
|
||||
# Uses: curl (download SVG) + sips (SVG->PNG conversion, built into macOS)
|
||||
#
|
||||
# Usage:
|
||||
# iconify_gen.sh <icon-id> <asset-name> [options]
|
||||
# iconify_gen.sh search <query> [--prefix <collection>] [--limit <n>]
|
||||
#
|
||||
# Examples:
|
||||
# iconify_gen.sh mdi:receipt-text-outline myExpenseIcon
|
||||
# iconify_gen.sh search "business card"
|
||||
# iconify_gen.sh search receipt --prefix mdi
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
API_BASE="https://api.iconify.design"
|
||||
readonly CURL_OPTS=(--fail --silent --show-error --connect-timeout 10 --max-time 30)
|
||||
|
||||
# Defaults
|
||||
SIZE=68
|
||||
COLOR="8E8E93"
|
||||
OUTPUT="/tmp/icons"
|
||||
LIMIT=20
|
||||
|
||||
require_value() {
|
||||
local flag="$1"
|
||||
local value="${2-}"
|
||||
if [[ -z "$value" || "$value" == --* ]]; then
|
||||
echo "ERROR: ${flag} requires a value" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
iconify_gen.sh <icon-id> <asset-name> [options] Generate an icon imageset
|
||||
iconify_gen.sh search <query> [options] Search for icons
|
||||
iconify_gen.sh preview <icon-id> Download preview SVG
|
||||
iconify_gen.sh collections List popular icon collections
|
||||
|
||||
Generate Options:
|
||||
--size <pt> Base size in points (default: 68)
|
||||
--color <hex> Color hex without # (default: 8E8E93)
|
||||
--output <dir> Output directory (default: /tmp/icons)
|
||||
|
||||
Search Options:
|
||||
--prefix <name> Filter by collection (e.g., mdi, lucide, tabler, ph)
|
||||
--limit <n> Max results (default: 20)
|
||||
|
||||
Icon ID Format: <collection>:<icon-name>
|
||||
Examples: mdi:receipt-text-outline, lucide:credit-card, ph:address-book
|
||||
|
||||
Popular Collections:
|
||||
mdi Material Design Icons (7400+ icons)
|
||||
lucide Lucide (1700+ icons)
|
||||
tabler Tabler Icons (6000+ icons)
|
||||
ph Phosphor (9000+ icons)
|
||||
ri Remix Icon (2800+ icons)
|
||||
carbon Carbon (2100+ icons)
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
search_icons() {
|
||||
local query="$1"
|
||||
shift
|
||||
local prefix=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--prefix) require_value --prefix "${2-}"; prefix="$2"; shift 2 ;;
|
||||
--limit) require_value --limit "${2-}"; LIMIT="$2"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
local encoded_query
|
||||
encoded_query="$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "$query")"
|
||||
local url="${API_BASE}/search?query=${encoded_query}&limit=${LIMIT}"
|
||||
if [[ -n "$prefix" ]]; then
|
||||
url="${url}&prefix=${prefix}"
|
||||
fi
|
||||
|
||||
local response
|
||||
response=$(curl "${CURL_OPTS[@]}" "$url") || { echo "ERROR: Search request failed"; exit 1; }
|
||||
|
||||
local total
|
||||
total=$(echo "$response" | python3 -c "import sys,json; print(json.load(sys.stdin).get('total',0))")
|
||||
|
||||
echo "Found ${total} icons for '${query}':"
|
||||
echo ""
|
||||
echo "$response" | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
for icon in data.get('icons', []):
|
||||
print(f' {icon}')
|
||||
"
|
||||
echo ""
|
||||
echo "Generate with: iconify_gen.sh <icon-id> <asset-name>"
|
||||
echo "Preview with: iconify_gen.sh preview <icon-id>"
|
||||
}
|
||||
|
||||
list_collections() {
|
||||
echo "Popular Iconify collections:"
|
||||
echo ""
|
||||
local resp
|
||||
resp=$(curl "${CURL_OPTS[@]}" "${API_BASE}/collections") || { echo "ERROR: Failed to fetch collections list"; exit 1; }
|
||||
echo "$resp" | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
popular = ['mdi','lucide','tabler','ph','ri','carbon','solar','heroicons','bi','octicon','ion','fe','charm','ci','iconoir','basil','uil','mingcute','flowbite','mynaui']
|
||||
for k in popular:
|
||||
if k in data:
|
||||
v = data[k]
|
||||
name = v.get('name','')
|
||||
total = v.get('total',0)
|
||||
print(f' {k:12s} {name} ({total} icons)')
|
||||
"
|
||||
echo ""
|
||||
echo "Full list: https://icon-sets.iconify.design/"
|
||||
}
|
||||
|
||||
preview_icon() {
|
||||
local icon_id="$1"
|
||||
local collection="${icon_id%%:*}"
|
||||
local name="${icon_id#*:}"
|
||||
local url="${API_BASE}/${collection}/${name}.svg?width=136&height=136&color=%23${COLOR}"
|
||||
local outfile="/tmp/iconify_preview_${collection}_${name}.svg"
|
||||
|
||||
curl "${CURL_OPTS[@]}" "$url" -o "$outfile" || { echo "ERROR: Icon '${icon_id}' not found"; exit 1; }
|
||||
echo "Preview SVG: ${outfile}"
|
||||
echo "URL: ${url}"
|
||||
|
||||
# Also convert to PNG for visual check
|
||||
local pngfile="/tmp/iconify_preview_${collection}_${name}.png"
|
||||
sips -s format png "$outfile" --out "$pngfile" >/dev/null 2>&1 || echo "WARNING: sips conversion failed; PNG may be incorrect"
|
||||
echo "Preview PNG: ${pngfile}"
|
||||
}
|
||||
|
||||
generate_icon() {
|
||||
local icon_id="$1"
|
||||
local asset_name="$2"
|
||||
shift 2
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--size) require_value --size "${2-}"; SIZE="$2"; shift 2 ;;
|
||||
--color) require_value --color "${2-}"; COLOR="$2"; shift 2 ;;
|
||||
--output) require_value --output "${2-}"; OUTPUT="$2"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
local collection="${icon_id%%:*}"
|
||||
local name="${icon_id#*:}"
|
||||
local imageset_dir="${OUTPUT}/${asset_name}.imageset"
|
||||
|
||||
mkdir -p "$imageset_dir"
|
||||
|
||||
echo "Generating ${asset_name} from Iconify '${icon_id}':"
|
||||
|
||||
local scales=("1:${SIZE}" "2:$((SIZE * 2))" "3:$((SIZE * 3))")
|
||||
|
||||
for scale_info in "${scales[@]}"; do
|
||||
local scale="${scale_info%%:*}"
|
||||
local px="${scale_info#*:}"
|
||||
local suffix=""
|
||||
[[ "$scale" != "1" ]] && suffix="@${scale}x"
|
||||
|
||||
local svg_url="${API_BASE}/${collection}/${name}.svg?width=${px}&height=${px}&color=%23${COLOR}"
|
||||
local svg_file="${imageset_dir}/${asset_name}${suffix}.svg"
|
||||
local png_file="${imageset_dir}/${asset_name}${suffix}.png"
|
||||
|
||||
curl "${CURL_OPTS[@]}" "$svg_url" -o "$svg_file" || { echo "ERROR: Failed to download icon '${icon_id}'"; exit 1; }
|
||||
sips -s format png "$svg_file" --out "$png_file" >/dev/null 2>&1 || echo "WARNING: sips conversion may have failed for ${svg_file}"
|
||||
rm "$svg_file"
|
||||
|
||||
echo " ${asset_name}${suffix}.png (${px}x${px})"
|
||||
done
|
||||
|
||||
# Write Contents.json
|
||||
cat > "${imageset_dir}/Contents.json" <<JSONEOF
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "${asset_name}.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "${asset_name}@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "${asset_name}@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
JSONEOF
|
||||
|
||||
echo "Output: ${imageset_dir}/"
|
||||
}
|
||||
|
||||
# Main
|
||||
[[ $# -eq 0 ]] && usage
|
||||
[[ "$1" == "--help" || "$1" == "-h" ]] && usage
|
||||
|
||||
case "$1" in
|
||||
search)
|
||||
shift
|
||||
[[ $# -eq 0 ]] && { echo "Usage: iconify_gen.sh search <query>"; exit 1; }
|
||||
search_icons "$@"
|
||||
;;
|
||||
preview)
|
||||
shift
|
||||
[[ $# -eq 0 ]] && { echo "Usage: iconify_gen.sh preview <icon-id>"; exit 1; }
|
||||
preview_icon "$1"
|
||||
;;
|
||||
collections)
|
||||
list_collections
|
||||
;;
|
||||
*)
|
||||
[[ $# -lt 2 ]] && { echo "Usage: iconify_gen.sh <icon-id> <asset-name> [options]"; exit 1; }
|
||||
generate_icon "$@"
|
||||
;;
|
||||
esac
|
||||
@@ -1,14 +1,6 @@
|
||||
---
|
||||
name: openclaw-persona-forge
|
||||
description: |-
|
||||
为 OpenClaw AI Agent 锻造完整的龙虾灵魂方案。根据用户偏好或随机抽卡,
|
||||
输出身份定位、灵魂描述(SOUL.md)、角色化底线规则、名字和头像生图提示词。
|
||||
如当前环境提供已审核的生图 skill,可自动生成统一风格头像图片。
|
||||
当用户需要创建、设计或定制 OpenClaw 龙虾灵魂时使用。
|
||||
不适用于:微调已有 SOUL.md、非 OpenClaw 平台的角色设计、纯工具型无性格 Agent。
|
||||
触发词:龙虾灵魂、虾魂、OpenClaw 灵魂、养虾灵魂、龙虾角色、龙虾定位、
|
||||
龙虾剧本杀角色、龙虾游戏角色、龙虾 NPC、龙虾性格、龙虾背景故事、
|
||||
lobster soul、lobster character、抽卡、随机龙虾、龙虾 SOUL、gacha。
|
||||
description: "为 OpenClaw AI Agent 锻造完整的龙虾灵魂方案。根据用户偏好或随机抽卡, 输出身份定位、灵魂描述(SOUL.md)、角色化底线规则、名字和头像生图提示词。 如当前环境提供已审核的生图 skill,可自动生成统一风格头像图片。 当用户需要创建、设计或定制 OpenClaw 龙虾灵魂时使用。 不适用于:微调已有 SOUL.md、非 OpenClaw 平台的角色设计、纯工具型无性格 Agent。 触发词:龙虾灵魂、虾魂、OpenClaw 灵魂、养虾灵魂、龙虾角色、龙虾定位、 龙虾剧本杀角色、龙虾游戏角色、龙虾 NPC、龙虾性格、龙虾背景故事、 lobster soul、lobster character、抽卡、随机龙虾、龙虾 SOUL、gacha。"
|
||||
origin: community
|
||||
---
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
name: skill-stocktake
|
||||
description: "Use when auditing Claude skills and commands for quality. Supports Quick Scan (changed skills only) and Full Stocktake modes with sequential subagent batch evaluation."
|
||||
origin: ECC
|
||||
---
|
||||
|
||||
@@ -46,11 +46,11 @@ Add to your `~/.claude/settings.json`:
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Edit",
|
||||
"hooks": [{ "type": "command", "command": "node ~/.claude/skills/strategic-compact/suggest-compact.js" }]
|
||||
"hooks": [{ "type": "command", "command": "node ~/.claude/scripts/hooks/suggest-compact.js" }]
|
||||
},
|
||||
{
|
||||
"matcher": "Write",
|
||||
"hooks": [{ "type": "command", "command": "node ~/.claude/skills/strategic-compact/suggest-compact.js" }]
|
||||
"hooks": [{ "type": "command", "command": "node ~/.claude/scripts/hooks/suggest-compact.js" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
---
|
||||
name: tinystruct-patterns
|
||||
description: Use when developing application modules or microservices with the tinystruct Java framework. Covers routing, context management, JSON handling with Builder, and CLI/HTTP dual-mode patterns.
|
||||
origin: ECC
|
||||
---
|
||||
|
||||
# tinystruct Development Patterns
|
||||
|
||||
Architecture and implementation patterns for building modules with the **tinystruct** Java framework – a lightweight system where CLI and HTTP are equal citizens.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Creating new `Application` modules by extending `AbstractApplication`.
|
||||
- Defining routes and command-line actions using `@Action`.
|
||||
- Handling per-request state via `Context`.
|
||||
- Performing JSON serialization using the native `Builder` component.
|
||||
- Configuring database connections or system settings in `application.properties`.
|
||||
- Generating or re-generating the standard `bin/dispatcher` entry point via `ApplicationManager.init()`.
|
||||
- Debugging routing conflicts (Actions) or CLI argument parsing.
|
||||
|
||||
## How It Works
|
||||
|
||||
The tinystruct framework treats any method annotated with `@Action` as a routable endpoint for both terminal and web environments. Applications are created by extending `AbstractApplication`, which provides core lifecycle hooks like `init()` and access to the request `Context`.
|
||||
|
||||
Routing is handled by the `ActionRegistry`, which automatically maps path segments to method arguments and injects dependencies. For data-only services, the native `Builder` component should be used for JSON serialization to maintain a zero-dependency footprint. The framework also includes a utility in `ApplicationManager` to bootstrap the project's execution environment by generating the `bin/dispatcher` script.
|
||||
|
||||
## Examples
|
||||
|
||||
### Basic Application (MyService)
|
||||
```java
|
||||
public class MyService extends AbstractApplication {
|
||||
@Override
|
||||
public void init() {
|
||||
this.setTemplateRequired(false); // Disable .view lookup for data/API apps
|
||||
}
|
||||
|
||||
@Override public String version() { return "1.0.0"; }
|
||||
|
||||
@Action("greet")
|
||||
public String greet() {
|
||||
return "Hello from tinystruct!";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Parameterized Routing (getUser)
|
||||
```java
|
||||
// Handles /api/user/123 (Web) or "bin/dispatcher api/user/123" (CLI)
|
||||
@Action("api/user/(\\d+)")
|
||||
public String getUser(int userId) {
|
||||
return "User ID: " + userId;
|
||||
}
|
||||
```
|
||||
|
||||
### HTTP Mode Disambiguation (login)
|
||||
```java
|
||||
@Action(value = "login", mode = Mode.HTTP_POST)
|
||||
public boolean doLogin() {
|
||||
// Process login logic
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
### Native JSON Data Handling (getData)
|
||||
```java
|
||||
@Action("api/data")
|
||||
public Builder getData() throws ApplicationException {
|
||||
Builder builder = new Builder();
|
||||
builder.put("status", "success");
|
||||
Builder nested = new Builder();
|
||||
nested.put("id", 1);
|
||||
nested.put("name", "James");
|
||||
builder.put("data", nested);
|
||||
return builder;
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Settings are managed in `src/main/resources/application.properties`.
|
||||
|
||||
```properties
|
||||
# Database
|
||||
driver=org.h2.Driver
|
||||
database.url=jdbc:h2:~/mydb
|
||||
|
||||
# App specific
|
||||
my.service.endpoint=https://api.example.com
|
||||
```
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
Use JUnit 5 to test actions by verifying they are registered in the `ActionRegistry`.
|
||||
|
||||
```java
|
||||
@Test
|
||||
void testActionRegistration() {
|
||||
Application app = new MyService();
|
||||
app.init();
|
||||
|
||||
ActionRegistry registry = ActionRegistry.getInstance();
|
||||
assertNotNull(registry.get("greet"));
|
||||
}
|
||||
```
|
||||
|
||||
## Red Flags & Anti-patterns
|
||||
|
||||
| Symptom | Correct Pattern |
|
||||
|---|---|
|
||||
| Importing `com.google.gson` or `com.fasterxml.jackson` | Use `org.tinystruct.data.component.Builder`. |
|
||||
| `FileNotFoundException` for `.view` files | Call `setTemplateRequired(false)` in `init()` for API-only apps. |
|
||||
| Annotating `private` methods with `@Action` | Actions must be `public` to be registered by the framework. |
|
||||
| Hardcoding `main(String[] args)` in apps | Use `bin/dispatcher` as the entry point for all modules. |
|
||||
| Manual `ActionRegistry` registration | Prefer the `@Action` annotation for automatic discovery. |
|
||||
|
||||
## Technical Reference
|
||||
|
||||
Detailed guides are available in the `references/` directory:
|
||||
|
||||
- [Architecture & Config](references/architecture.md) — Abstractions, Package Map, Properties
|
||||
- [Routing & @Action](references/routing.md) — Annotation details, Modes, Parameters
|
||||
- [Data Handling](references/data-handling.md) — Using the native `Builder` for JSON
|
||||
- [System & Usage](references/system-usage.md) — Context, Sessions, Events, CLI usage
|
||||
- [Testing Patterns](references/testing.md) — JUnit 5 integration and ActionRegistry testing
|
||||
|
||||
## Reference Source Files (Internal)
|
||||
|
||||
- `src/main/java/org/tinystruct/AbstractApplication.java` — Core base class
|
||||
- `src/main/java/org/tinystruct/system/annotation/Action.java` — Annotation & Modes
|
||||
- `src/main/java/org/tinystruct/application/ActionRegistry.java` — Routing Engine
|
||||
- `src/main/java/org/tinystruct/data/component/Builder.java` — JSON/Data Serializer
|
||||
@@ -0,0 +1,77 @@
|
||||
# tinystruct Architecture and Configuration
|
||||
|
||||
## When to Use
|
||||
|
||||
Choose **tinystruct** when you need a lightweight, high-performance Java framework that treats CLI and HTTP as equal citizens. It is ideal for building microservices, command-line utilities, and data-driven applications where a small footprint and zero-dependency JSON handling are required. Use it when you want to write logic once and expose it via both a terminal and a web server without modification.
|
||||
|
||||
## How It Works
|
||||
|
||||
### Core Architecture
|
||||
|
||||
The framework operates on a singleton `ActionRegistry` that maps URL patterns (or command strings) to `Action` objects. When a request arrives, the system resolves the path and invokes the corresponding method handle.
|
||||
|
||||
#### Key Abstractions
|
||||
|
||||
| Class/Interface | Role |
|
||||
|---|---|
|
||||
| `AbstractApplication` | Base class for all tinystruct applications. Extend this. |
|
||||
| `@Action` annotation | Maps a method to a URI path (web) or command name (CLI). The single routing primitive. |
|
||||
| `ActionRegistry` | Singleton that maps URL patterns to `Action` objects via regex. Never instantiate directly. |
|
||||
| `Action` | Wraps a `MethodHandle` + regex pattern + priority + `Mode` for dispatch. |
|
||||
| `Context` | Per-request state store. Access via `getContext()`. Holds CLI args and HTTP request/response. |
|
||||
| `Dispatcher` | CLI entry point (`bin/dispatcher`). Reads `--import` to load applications. |
|
||||
| `HttpServer` | Built-in Netty-based HTTP server. Start with `bin/dispatcher start --import org.tinystruct.system.HttpServer`. |
|
||||
|
||||
### Package Map
|
||||
|
||||
```
|
||||
org.tinystruct/
|
||||
├── AbstractApplication.java ← extend this
|
||||
├── Application.java ← interface
|
||||
├── ApplicationException.java ← checked exception
|
||||
├── ApplicationRuntimeException.java ← unchecked exception
|
||||
├── application/
|
||||
│ ├── Action.java ← runtime action wrapper
|
||||
│ ├── ActionRegistry.java ← singleton route registry
|
||||
│ └── Context.java ← request context
|
||||
├── system/
|
||||
│ ├── annotation/Action.java ← @Action annotation + Mode enum
|
||||
│ ├── Dispatcher.java ← CLI dispatcher
|
||||
│ ├── HttpServer.java ← built-in HTTP server
|
||||
│ ├── EventDispatcher.java ← event bus
|
||||
│ └── Settings.java ← reads application.properties
|
||||
├── data/component/Builder.java ← JSON serialization (use instead of Gson/Jackson)
|
||||
└── http/ ← Request, Response, Constants
|
||||
```
|
||||
|
||||
### Template Behavior and Dispatch Flow
|
||||
|
||||
By default, the framework assumes a view template is required. If `templateRequired` is `true`, `toString()` looks for a `.view` file in `src/main/resources/themes/<ClassName>.view`. Use `getContext()` to manage state and `setVariable("name", value)` to pass data to templates, which use `[%name%]` for interpolation.
|
||||
|
||||
## Examples
|
||||
|
||||
### Minimal Application Initialization
|
||||
```java
|
||||
@Override
|
||||
public void init() {
|
||||
this.setTemplateRequired(false); // Skip .view template lookup for data-only apps
|
||||
}
|
||||
```
|
||||
|
||||
### Action Definition and CLI Invocation
|
||||
```java
|
||||
@Action("hello")
|
||||
public String hello() {
|
||||
return "Hello, tinystruct!";
|
||||
}
|
||||
```
|
||||
**Execution via Dispatcher:**
|
||||
```bash
|
||||
bin/dispatcher hello
|
||||
```
|
||||
|
||||
### Configuration Access
|
||||
Located at `src/main/resources/application.properties`:
|
||||
```java
|
||||
String port = this.getConfiguration("server.port");
|
||||
```
|
||||
@@ -0,0 +1,35 @@
|
||||
# tinystruct Data Handling (JSON)
|
||||
|
||||
## When to Use
|
||||
|
||||
Prefer `org.tinystruct.data.component.Builder` in scenarios where you need a lightweight, high-performance JSON solution with **zero external dependencies**. It is specifically designed to keep your tinystruct applications lean and fast, making it the ideal choice for microservices and CLI tools where including heavy libraries like Jackson or Gson would be overkill.
|
||||
|
||||
## How It Works
|
||||
|
||||
The `Builder` class provides a simple key-value interface for both creating and reading JSON structures. It integrates directly with `AbstractApplication` result handling; when an action method returns a `Builder` object, the framework automatically serializes it to the response stream. This prevents the need for manual string conversion and ensures consistent data formatting across your application modules.
|
||||
|
||||
## Examples
|
||||
|
||||
### Serialization
|
||||
```java
|
||||
import org.tinystruct.data.component.Builder;
|
||||
|
||||
// Create and populate
|
||||
Builder response = new Builder();
|
||||
response.put("status", "success");
|
||||
response.put("count", 42);
|
||||
response.put("data", someList);
|
||||
|
||||
return response; // {"status":"success","count":42,...}
|
||||
```
|
||||
|
||||
### Parsing
|
||||
```java
|
||||
import org.tinystruct.data.component.Builder;
|
||||
|
||||
// Parse a JSON string
|
||||
Builder parsed = new Builder();
|
||||
parsed.parse(jsonString);
|
||||
|
||||
String status = parsed.get("status").toString();
|
||||
```
|
||||
@@ -0,0 +1,57 @@
|
||||
# tinystruct @Action Routing Reference
|
||||
|
||||
## When to Use
|
||||
|
||||
Use the `@Action` annotation in your applications to define routes for both CLI commands and HTTP endpoints. It is appropriate whenever you need to map logic to a specific path, handle parameterized requests (e.g., retrieving a resource by ID), or restrict execution to specific HTTP methods (GET, POST, etc.) while maintaining a consistent command structure across environments.
|
||||
|
||||
## How It Works
|
||||
|
||||
The `ActionRegistry` parses `@Action` annotations to build a routing table. For parameterized methods, the framework automatically maps Java parameter types (int, String, etc.) to corresponding regex segments to generate an internal matching pattern. For instance, `getUser(int id)` generates a regex targeting digits, while `search(String query)` targets generic path segments.
|
||||
|
||||
When a request is dispatched, the `ActionRegistry` automatically injects dependencies like `Request` and `Response` into the action method if they are specified as parameters, drawing them directly from the current request's `Context`. Execution is further filtered by the `Mode` value, allowing a single path to invoke different logic depending on whether the trigger was a terminal command or a specific type of HTTP request.
|
||||
|
||||
### Mode Values
|
||||
|
||||
| Mode | When it triggers |
|
||||
|---|---|
|
||||
| `DEFAULT` | Both CLI and HTTP (GET, POST, etc.) |
|
||||
| `CLI` | CLI dispatcher only |
|
||||
| `HTTP_GET` | HTTP GET only |
|
||||
| `HTTP_POST` | HTTP POST only |
|
||||
| `HTTP_PUT` | HTTP PUT only |
|
||||
| `HTTP_DELETE` | HTTP DELETE only |
|
||||
| `HTTP_PATCH` | HTTP PATCH only |
|
||||
|
||||
## Examples
|
||||
|
||||
### Basic Action Declaration
|
||||
```java
|
||||
@Action(
|
||||
value = "path/subpath", // required: URI segment or CLI command
|
||||
description = "What it does", // shown in --help output
|
||||
mode = Mode.HTTP_POST, // default: Mode.DEFAULT (both CLI + HTTP)
|
||||
options = {}, // CLI option flags
|
||||
example = "curl -X POST http://localhost:8080/path/subpath/42"
|
||||
)
|
||||
public String myAction(int id) { ... }
|
||||
```
|
||||
|
||||
### Parameterized Paths (Regex Generation)
|
||||
```java
|
||||
@Action("user/{id}")
|
||||
public String getUser(int id) { ... }
|
||||
// → pattern: ^/?user/(-?\d+)$
|
||||
|
||||
@Action("search")
|
||||
public String search(String query) { ... }
|
||||
// → pattern: ^/?search/([^/]+)$
|
||||
```
|
||||
|
||||
### Request and Response Injection
|
||||
```java
|
||||
@Action(value = "upload", mode = Mode.HTTP_POST)
|
||||
public String upload(Request<?, ?> req, Response<?, ?> res) throws ApplicationException {
|
||||
// req.getParameter("file"), res.setHeader(...), etc.
|
||||
return "ok";
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,74 @@
|
||||
# tinystruct System and Usage Reference
|
||||
|
||||
## When to Use
|
||||
|
||||
Use the system and usage patterns described here when you need to handle stateful interactions across CLI and HTTP modes, manage user sessions in web applications, or implement loosely coupled communication between application modules using an event-driven architecture.
|
||||
|
||||
## How It Works
|
||||
|
||||
The framework's `Context` serves as the primary data store for request-specific state. In CLI mode, flags passed as `--key value` are automatically parsed and stored in the `Context` with the `--` prefix, allowing action methods to retrieve command parameters easily. For web applications, the system provides standard session management via the `Request` object, enabling the storage of user data across multiple HTTP requests.
|
||||
|
||||
The internal `EventDispatcher` facilitates an asynchronous event bus. By defining custom `Event` classes and registering handlers (typically within an application's `init()` method), you can trigger background tasks—such as sending emails or logging audit trails—without blocking the main execution path.
|
||||
|
||||
## Examples
|
||||
|
||||
### Context and CLI Arguments
|
||||
```java
|
||||
@Action("echo")
|
||||
public String echo() {
|
||||
// CLI: bin/dispatcher echo --words "Hello World"
|
||||
Object words = getContext().getAttribute("--words");
|
||||
if (words != null) return words.toString();
|
||||
return "No words provided";
|
||||
}
|
||||
```
|
||||
|
||||
### Session Management (Web Mode)
|
||||
```java
|
||||
@Action(value = "login", mode = Mode.HTTP_POST)
|
||||
public String login(Request request) {
|
||||
request.getSession().setAttribute("userId", "42");
|
||||
return "Logged in";
|
||||
}
|
||||
|
||||
@Action("profile")
|
||||
public String profile(Request request) {
|
||||
Object userId = request.getSession().getAttribute("userId");
|
||||
if (userId == null) return "Not logged in";
|
||||
return "User: " + userId;
|
||||
}
|
||||
```
|
||||
|
||||
### Event System
|
||||
```java
|
||||
// 1. Define an event
|
||||
public class OrderCreatedEvent implements org.tinystruct.system.Event<Order> {
|
||||
private final Order order;
|
||||
public OrderCreatedEvent(Order order) { this.order = order; }
|
||||
|
||||
@Override public String getName() { return "order_created"; }
|
||||
@Override public Order getPayload() { return order; }
|
||||
}
|
||||
|
||||
// 2. Register a handler
|
||||
EventDispatcher.getInstance().registerHandler(OrderCreatedEvent.class, event -> {
|
||||
CompletableFuture.runAsync(() -> sendConfirmationEmail(event.getPayload()));
|
||||
});
|
||||
|
||||
// 3. Dispatch
|
||||
EventDispatcher.getInstance().dispatch(new OrderCreatedEvent(newOrder));
|
||||
```
|
||||
|
||||
### Running the Application
|
||||
```bash
|
||||
# CLI mode
|
||||
bin/dispatcher hello
|
||||
bin/dispatcher echo --words "Hello" --import com.example.HelloApp
|
||||
|
||||
# HTTP server (listens on :8080 by default)
|
||||
bin/dispatcher start --import org.tinystruct.system.HttpServer
|
||||
|
||||
# Database utilities
|
||||
bin/dispatcher generate --table users
|
||||
bin/dispatcher sql-query "SELECT * FROM users"
|
||||
```
|
||||
@@ -0,0 +1,59 @@
|
||||
# tinystruct Testing Patterns
|
||||
|
||||
## When to Use
|
||||
|
||||
Use the testing patterns described here when writing units tests for your tinystruct applications with **JUnit 5**. These patterns are essential for verifying that your `@Action` methods return the correct results and that your routing logic is properly registered within the singleton `ActionRegistry`.
|
||||
|
||||
## How It Works
|
||||
|
||||
Testing tinystruct applications requires a specific setup to ensure framework-level features like annotation processing and configuration management are active. By creating a new instance of your application and passing it a `Settings` object in the `setUp()` method, you trigger the `init()` lifecycle. This ensures all `@Action` methods are discovered and registered.
|
||||
|
||||
Because the `ActionRegistry` is a singleton, it is critical to maintain isolation between tests by properly initializing your application state before each test execution, preventing side effects from leaking across the test suite.
|
||||
|
||||
## Examples
|
||||
|
||||
### Unit Testing an Application
|
||||
```java
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.tinystruct.application.ActionRegistry;
|
||||
import org.tinystruct.system.Settings;
|
||||
|
||||
class MyAppTest {
|
||||
|
||||
private MyApp app;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
app = new MyApp();
|
||||
Settings config = new Settings();
|
||||
app.setConfiguration(config);
|
||||
app.init(); // triggers @Action annotation processing
|
||||
}
|
||||
void testHello() throws Exception {
|
||||
// Direct invocation via the application object
|
||||
Object result = app.invoke("hello");
|
||||
Assertions.assertEquals("Hello, tinystruct!", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGreet() throws Exception {
|
||||
// Invocation with arguments
|
||||
Object result = app.invoke("greet", new Object[]{"James"});
|
||||
Assertions.assertEquals("Hello, James!", result);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testing via ActionRegistry
|
||||
If you need to test the routing logic itself, use the `ActionRegistry` singleton to verify path matching:
|
||||
|
||||
```java
|
||||
@Test
|
||||
void testRouting() {
|
||||
ActionRegistry registry = ActionRegistry.getInstance();
|
||||
// Verify a path matches an action
|
||||
Action action = registry.getAction("greet/James");
|
||||
Assertions.assertNotNull(action);
|
||||
}
|
||||
```
|
||||
Reference: `src/test/java/org/tinystruct/application/ActionRegistryTest.java`
|
||||
@@ -57,6 +57,24 @@ class ToolDefinition:
|
||||
"strict": self.strict,
|
||||
}
|
||||
|
||||
def to_openai_tool(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"parameters": self.parameters,
|
||||
"strict": self.strict,
|
||||
},
|
||||
}
|
||||
|
||||
def to_anthropic_tool(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"input_schema": self.parameters,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolCall:
|
||||
|
||||
@@ -60,7 +60,7 @@ class ClaudeProvider(LLMProvider):
|
||||
else:
|
||||
params["max_tokens"] = 8192 # required by Anthropic API
|
||||
if input.tools:
|
||||
params["tools"] = [tool.to_dict() for tool in input.tools]
|
||||
params["tools"] = [tool.to_anthropic_tool() for tool in input.tools]
|
||||
|
||||
response = self.client.messages.create(**params)
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ class OpenAIProvider(LLMProvider):
|
||||
if input.max_tokens:
|
||||
params["max_tokens"] = input.max_tokens
|
||||
if input.tools:
|
||||
params["tools"] = [tool.to_dict() for tool in input.tools]
|
||||
params["tools"] = [tool.to_openai_tool() for tool in input.tools]
|
||||
|
||||
response = self.client.chat.completions.create(**params)
|
||||
choice = response.choices[0]
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from llm.core.interface import LLMProvider
|
||||
from llm.core.types import ProviderType
|
||||
@@ -17,10 +18,45 @@ _PROVIDER_MAP: dict[ProviderType, type[LLMProvider]] = {
|
||||
ProviderType.OLLAMA: OllamaProvider,
|
||||
}
|
||||
|
||||
LLM_ENV_FILE = ".llm.env"
|
||||
|
||||
|
||||
def _strip_env_value(value: str) -> str:
|
||||
value = value.strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
||||
return value[1:-1]
|
||||
return value
|
||||
|
||||
|
||||
def _read_saved_llm_config(env_path: str | Path = LLM_ENV_FILE) -> dict[str, str]:
|
||||
path = Path(env_path)
|
||||
if not path.is_file():
|
||||
return {}
|
||||
|
||||
config: dict[str, str] = {}
|
||||
for line in path.read_text().splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
||||
continue
|
||||
key, value = stripped.split("=", 1)
|
||||
config[key.strip()] = _strip_env_value(value)
|
||||
return config
|
||||
|
||||
|
||||
def _resolve_provider_type(provider_type: ProviderType | str | None) -> ProviderType | str:
|
||||
if provider_type is not None:
|
||||
return provider_type
|
||||
|
||||
env_provider = os.environ.get("LLM_PROVIDER")
|
||||
if env_provider:
|
||||
return _strip_env_value(env_provider).lower()
|
||||
|
||||
saved_config = _read_saved_llm_config()
|
||||
return saved_config.get("LLM_PROVIDER", "claude").lower()
|
||||
|
||||
|
||||
def get_provider(provider_type: ProviderType | str | None = None, **kwargs: str) -> LLMProvider:
|
||||
if provider_type is None:
|
||||
provider_type = os.environ.get("LLM_PROVIDER", "claude").lower()
|
||||
provider_type = _resolve_provider_type(provider_type)
|
||||
|
||||
if isinstance(provider_type, str):
|
||||
try:
|
||||
|
||||
@@ -44,6 +44,7 @@ function writeEnglishReadme(root, counts, options = {}) {
|
||||
const unrelatedSkillsCount = options.unrelatedSkillsCount || 16;
|
||||
|
||||
fs.writeFileSync(path.join(root, 'README.md'), `Access to ${counts.agents} agents, ${counts.skills} skills, and ${counts.commands} commands.
|
||||
|-- agents/ # ${counts.agents} specialized subagents for delegation
|
||||
| Feature | Claude Code | Cursor IDE | Codex CLI | OpenCode |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Agents | PASS: ${tableCounts.agents} agents |
|
||||
@@ -64,6 +65,22 @@ function writeEnglishReadme(root, counts, options = {}) {
|
||||
`);
|
||||
}
|
||||
|
||||
function writePluginMetadata(root, counts) {
|
||||
const pluginDir = path.join(root, '.claude-plugin');
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(path.join(pluginDir, 'plugin.json'), JSON.stringify({
|
||||
name: 'ecc',
|
||||
description: `Fixture plugin — ${counts.agents} agents, ${counts.skills} skills, ${counts.commands} legacy command shims`,
|
||||
}, null, 2));
|
||||
fs.writeFileSync(path.join(pluginDir, 'marketplace.json'), JSON.stringify({
|
||||
plugins: [{
|
||||
name: 'ecc',
|
||||
description: `Fixture marketplace plugin — ${counts.agents} agents, ${counts.skills} skills, ${counts.commands} legacy command shims`,
|
||||
}],
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
function writeEnglishAgents(root, counts, options = {}) {
|
||||
const plus = options.skillsMinimum ? '+' : '';
|
||||
|
||||
@@ -143,6 +160,7 @@ function writeCatalogFixture(root, options = {}) {
|
||||
writeZhRootReadme(root, documentedCounts);
|
||||
writeZhDocsReadme(root, documentedCounts, { unrelatedSkillsCount });
|
||||
writeZhAgents(root, documentedCounts, { skillsMinimum });
|
||||
writePluginMetadata(root, documentedCounts);
|
||||
}
|
||||
|
||||
function test(name, fn) {
|
||||
@@ -203,7 +221,10 @@ function runTests() {
|
||||
.join('\n');
|
||||
|
||||
assert.ok(formatted.includes('README.md quick-start summary'));
|
||||
assert.ok(formatted.includes('README.md project tree'));
|
||||
assert.ok(formatted.includes('AGENTS.md summary'));
|
||||
assert.ok(formatted.includes('.claude-plugin/plugin.json description'));
|
||||
assert.ok(formatted.includes('.claude-plugin/marketplace.json plugin description'));
|
||||
assert.ok(formatted.includes('README.zh-CN.md quick-start summary'));
|
||||
assert.ok(formatted.includes('docs/zh-CN/README.md parity table'));
|
||||
assert.ok(formatted.includes('docs/zh-CN/AGENTS.md project structure'));
|
||||
@@ -230,14 +251,19 @@ function runTests() {
|
||||
const agentsDoc = fs.readFileSync(path.join(testDir, 'AGENTS.md'), 'utf8');
|
||||
const zhReadme = fs.readFileSync(path.join(testDir, 'docs', 'zh-CN', 'README.md'), 'utf8');
|
||||
const zhAgentsDoc = fs.readFileSync(path.join(testDir, 'docs', 'zh-CN', 'AGENTS.md'), 'utf8');
|
||||
const pluginJson = fs.readFileSync(path.join(testDir, '.claude-plugin', 'plugin.json'), 'utf8');
|
||||
const marketplaceJson = fs.readFileSync(path.join(testDir, '.claude-plugin', 'marketplace.json'), 'utf8');
|
||||
|
||||
assert.ok(readme.includes('Access to 1 agents, 1 skills, and 1 legacy command shims'));
|
||||
assert.ok(readme.includes('|-- agents/ # 1 specialized subagents for delegation'));
|
||||
assert.ok(readme.includes('| Skills | 42 | .agents/skills/ |'));
|
||||
assert.ok(agentsDoc.includes('providing 1 specialized agents, 1+ skills, 1 commands'));
|
||||
assert.ok(agentsDoc.includes('skills/ - 1+ workflow skills and domain knowledge'));
|
||||
assert.ok(zhReadme.includes('| 技能 | 42 | .agents/skills/ |'));
|
||||
assert.ok(zhAgentsDoc.includes('提供 1 个专业代理、1+ 项技能、1 条命令'));
|
||||
assert.ok(zhAgentsDoc.includes('skills/ - 1+ 个工作流技能和领域知识'));
|
||||
assert.ok(pluginJson.includes('1 agents, 1 skills, 1 legacy command shims'));
|
||||
assert.ok(marketplaceJson.includes('1 agents, 1 skills, 1 legacy command shims'));
|
||||
} finally {
|
||||
cleanupTestDir(testDir);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Tests for scripts/ci/validate-no-personal-paths.js.
|
||||
*
|
||||
* Run with: node tests/ci/no-personal-paths.test.js
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const repoRoot = path.join(__dirname, '..', '..');
|
||||
const validatorPath = path.join(repoRoot, 'scripts', 'ci', 'validate-no-personal-paths.js');
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createTestDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'no-personal-paths-test-'));
|
||||
}
|
||||
|
||||
function cleanupTestDir(testDir) {
|
||||
fs.rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function writeFile(filePath, content) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, content);
|
||||
}
|
||||
|
||||
function stripShebang(source) {
|
||||
let result = source;
|
||||
if (result.charCodeAt(0) === 0xFEFF) result = result.slice(1);
|
||||
if (result.startsWith('#!')) {
|
||||
const newline = result.indexOf('\n');
|
||||
result = newline === -1 ? '' : result.slice(newline + 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function runValidatorAgainst(testDir) {
|
||||
let source = fs.readFileSync(validatorPath, 'utf8');
|
||||
source = stripShebang(source);
|
||||
source = source.replace(
|
||||
/const ROOT = .*?;/,
|
||||
`const ROOT = ${JSON.stringify(testDir)};`,
|
||||
);
|
||||
|
||||
const tmpFile = path.join(
|
||||
os.tmpdir(),
|
||||
`no-personal-paths-${Date.now()}-${Math.random().toString(36).slice(2)}.js`,
|
||||
);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(tmpFile, source, 'utf8');
|
||||
const stdout = execFileSync('node', [tmpFile], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000,
|
||||
cwd: repoRoot,
|
||||
});
|
||||
return { code: 0, stdout, stderr: '' };
|
||||
} catch (err) {
|
||||
return {
|
||||
code: err.status || 1,
|
||||
stdout: err.stdout || '',
|
||||
stderr: err.stderr || '',
|
||||
};
|
||||
} finally {
|
||||
try { fs.unlinkSync(tmpFile); } catch (_) { /* ignore cleanup errors */ }
|
||||
}
|
||||
}
|
||||
|
||||
function runValidatorAgainstRealRepo() {
|
||||
try {
|
||||
const stdout = execFileSync('node', [validatorPath], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000,
|
||||
cwd: repoRoot,
|
||||
});
|
||||
return { code: 0, stdout, stderr: '' };
|
||||
} catch (err) {
|
||||
return {
|
||||
code: err.status || 1,
|
||||
stdout: err.stdout || '',
|
||||
stderr: err.stderr || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n=== Testing validate-no-personal-paths.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function record(ok) {
|
||||
if (ok) passed += 1;
|
||||
else failed += 1;
|
||||
}
|
||||
|
||||
record(test('passes against the real repository', () => {
|
||||
const result = runValidatorAgainstRealRepo();
|
||||
assert.strictEqual(result.code, 0, `expected exit 0; stderr: ${result.stderr}`);
|
||||
assert.ok(result.stdout.includes('Validated:'), 'expected success line in stdout');
|
||||
}));
|
||||
|
||||
record(test('flags a leaked /Users/<name> path', () => {
|
||||
const testDir = createTestDir();
|
||||
try {
|
||||
writeFile(path.join(testDir, 'skills', 'leaky', 'SKILL.md'), 'See /Users/sugig/.claude/settings.json\n');
|
||||
const result = runValidatorAgainst(testDir);
|
||||
assert.strictEqual(result.code, 1, 'expected non-zero exit on leak');
|
||||
assert.ok(result.stderr.includes('/Users/sugig'), `expected stderr to mention leaked path; got: ${result.stderr}`);
|
||||
assert.ok(result.stderr.includes('skills/leaky/SKILL.md'), `expected normalized file path; got: ${result.stderr}`);
|
||||
} finally {
|
||||
cleanupTestDir(testDir);
|
||||
}
|
||||
}));
|
||||
|
||||
record(test('flags a leaked C:\\Users\\<name> path case-insensitively', () => {
|
||||
const testDir = createTestDir();
|
||||
try {
|
||||
writeFile(path.join(testDir, 'docs', 'guide.md'), 'See C:\\Users\\Affaan\\projects\\thing\n');
|
||||
const result = runValidatorAgainst(testDir);
|
||||
assert.strictEqual(result.code, 1, 'expected non-zero exit on leak');
|
||||
assert.ok(result.stderr.includes('C:\\Users\\Affaan'), `expected stderr to mention leaked path; got: ${result.stderr}`);
|
||||
} finally {
|
||||
cleanupTestDir(testDir);
|
||||
}
|
||||
}));
|
||||
|
||||
record(test('allows /Users/<placeholder> templates', () => {
|
||||
const testDir = createTestDir();
|
||||
try {
|
||||
writeFile(path.join(testDir, 'commands', 'demo.md'), [
|
||||
'/Users/you/.claude/session.json',
|
||||
'/Users/example/.claude/rules/foo.md',
|
||||
'/Users/yourname/projects/app',
|
||||
'/Users/your-username/.claude/settings.json',
|
||||
'C:\\Users\\USER\\.claude\\settings.json',
|
||||
].join('\n'));
|
||||
const result = runValidatorAgainst(testDir);
|
||||
assert.strictEqual(result.code, 0, `expected exit 0 for placeholders; stderr: ${result.stderr}`);
|
||||
} finally {
|
||||
cleanupTestDir(testDir);
|
||||
}
|
||||
}));
|
||||
|
||||
record(test('exempts docs/fixes forensic reports', () => {
|
||||
const testDir = createTestDir();
|
||||
try {
|
||||
writeFile(
|
||||
path.join(testDir, 'docs', 'fixes', 'HOOK-FIX-EXAMPLE.md'),
|
||||
'Reporter ran: C:\\Users\\sugig\\.claude\\settings.local.json\n',
|
||||
);
|
||||
const result = runValidatorAgainst(testDir);
|
||||
assert.strictEqual(result.code, 0, `expected exit 0 for docs/fixes; stderr: ${result.stderr}`);
|
||||
} finally {
|
||||
cleanupTestDir(testDir);
|
||||
}
|
||||
}));
|
||||
|
||||
record(test('only scans configured file extensions', () => {
|
||||
const testDir = createTestDir();
|
||||
try {
|
||||
writeFile(path.join(testDir, 'skills', 'demo', 'image.png'), 'binary /Users/sugig/secret');
|
||||
const result = runValidatorAgainst(testDir);
|
||||
assert.strictEqual(result.code, 0, `expected non-text extensions to be skipped; stderr: ${result.stderr}`);
|
||||
} finally {
|
||||
cleanupTestDir(testDir);
|
||||
}
|
||||
}));
|
||||
|
||||
record(test('reports every leak on a single offending file', () => {
|
||||
const testDir = createTestDir();
|
||||
try {
|
||||
writeFile(path.join(testDir, 'skills', 'multi', 'SKILL.md'), [
|
||||
'/Users/sugig/.claude/a.json',
|
||||
'/Users/sugig/.claude/b.json',
|
||||
'C:\\Users\\foo\\bar',
|
||||
].join('\n'));
|
||||
const result = runValidatorAgainst(testDir);
|
||||
assert.strictEqual(result.code, 1, 'expected non-zero exit on leak');
|
||||
const sugigCount = (result.stderr.match(/\/Users\/sugig/g) || []).length;
|
||||
const fooCount = (result.stderr.match(/C:\\Users\\foo/g) || []).length;
|
||||
assert.strictEqual(sugigCount, 2, `expected both /Users/sugig occurrences reported; got: ${result.stderr}`);
|
||||
assert.strictEqual(fooCount, 1, `expected C:\\Users\\foo reported once; got: ${result.stderr}`);
|
||||
} finally {
|
||||
cleanupTestDir(testDir);
|
||||
}
|
||||
}));
|
||||
|
||||
console.log(`\nPassed: ${passed}`);
|
||||
console.log(`Failed: ${failed}\n`);
|
||||
|
||||
if (failed > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -81,6 +81,24 @@ function run() {
|
||||
assert.match(result.stderr, /pull_request\.head\.sha/);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Quoted action names are valid YAML. The checkout-step filter must still
|
||||
// inspect their `with.ref` values in privileged workflows.
|
||||
if (test('rejects pull_request_target checkout when uses is double-quoted', () => {
|
||||
const result = runValidator({
|
||||
'unsafe-double-quoted.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 on double-quoted uses:');
|
||||
assert.match(result.stderr, /pull_request\.head\.sha/);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects pull_request_target checkout when uses is single-quoted', () => {
|
||||
const result = runValidator({
|
||||
'unsafe-single-quoted.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 on single-quoted uses:');
|
||||
assert.match(result.stderr, /pull_request\.head\.sha/);
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nPassed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
|
||||
|
||||
+265
-3
@@ -11,7 +11,7 @@ const assert = require('assert');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const { execFileSync } = require('child_process');
|
||||
const { execFileSync, spawnSync } = require('child_process');
|
||||
|
||||
const validatorsDir = path.join(__dirname, '..', '..', 'scripts', 'ci');
|
||||
const repoRoot = path.join(__dirname, '..', '..');
|
||||
@@ -169,6 +169,8 @@ function runCatalogValidator(overrides = {}) {
|
||||
README_ZH_CN_PATH: path.join(repoRoot, 'README.zh-CN.md'),
|
||||
DOCS_ZH_CN_README_PATH: path.join(repoRoot, 'docs', 'zh-CN', 'README.md'),
|
||||
DOCS_ZH_CN_AGENTS_PATH: path.join(repoRoot, 'docs', 'zh-CN', 'AGENTS.md'),
|
||||
PLUGIN_JSON_PATH: path.join(repoRoot, '.claude-plugin', 'plugin.json'),
|
||||
MARKETPLACE_JSON_PATH: path.join(repoRoot, '.claude-plugin', 'marketplace.json'),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@@ -180,9 +182,52 @@ function runCatalogValidator(overrides = {}) {
|
||||
return runSourceViaTempFile(source);
|
||||
}
|
||||
|
||||
// Run validate-skills.js against a fixture dir, optionally passing
|
||||
// extra argv (e.g. '--strict') and env overrides (e.g.
|
||||
// CI_STRICT_SKILLS=1) so the frontmatter finding suite can exercise
|
||||
// both warn and strict modes via argv and env code paths.
|
||||
//
|
||||
// Captures stderr on both success and failure (the shared
|
||||
// runSourceViaTempFile helper only surfaces stderr when the child
|
||||
// exits non-zero, which hides WARN lines in the default mode).
|
||||
function runSkillsValidator(testDir, argv = [], envOverrides = {}) {
|
||||
const validatorPath = path.join(validatorsDir, 'validate-skills.js');
|
||||
let source = fs.readFileSync(validatorPath, 'utf8');
|
||||
source = stripShebang(source);
|
||||
source = source.replace(
|
||||
/const SKILLS_DIR = .*?;/,
|
||||
`const SKILLS_DIR = ${JSON.stringify(testDir)};`,
|
||||
);
|
||||
if (argv.length > 0) {
|
||||
const argvPreamble = argv
|
||||
.map(arg => `process.argv.push(${JSON.stringify(arg)});`)
|
||||
.join('\n');
|
||||
source = `${argvPreamble}\n${source}`;
|
||||
}
|
||||
const tmpFile = path.join(repoRoot,
|
||||
`.tmp-validator-${Date.now()}-${Math.random().toString(36).slice(2)}.js`);
|
||||
try {
|
||||
fs.writeFileSync(tmpFile, source, 'utf8');
|
||||
const r = spawnSync('node', [tmpFile], {
|
||||
encoding: 'utf8',
|
||||
timeout: 10000,
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env, CI_STRICT_SKILLS: '', ...envOverrides },
|
||||
});
|
||||
return {
|
||||
code: typeof r.status === 'number' ? r.status : 1,
|
||||
stdout: r.stdout || '',
|
||||
stderr: r.stderr || '',
|
||||
};
|
||||
} finally {
|
||||
try { fs.unlinkSync(tmpFile); } catch (_) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
function writeCatalogFixture(testDir, options = {}) {
|
||||
const {
|
||||
readmeCounts = { agents: 1, skills: 1, commands: 1 },
|
||||
readmeProjectTreeAgents = readmeCounts.agents,
|
||||
readmeTableCounts = readmeCounts,
|
||||
readmeParityCounts = readmeCounts,
|
||||
readmeUnrelatedSkillsCount = 16,
|
||||
@@ -203,6 +248,8 @@ function writeCatalogFixture(testDir, options = {}) {
|
||||
'skills/ — 1 个工作流技能和领域知识',
|
||||
'commands/ — 1 个斜杠命令',
|
||||
],
|
||||
pluginCounts = { agents: 1, skills: 1, commands: 1 },
|
||||
marketplaceCounts = { agents: 1, skills: 1, commands: 1 },
|
||||
} = options;
|
||||
|
||||
const readmePath = path.join(testDir, 'README.md');
|
||||
@@ -210,23 +257,36 @@ function writeCatalogFixture(testDir, options = {}) {
|
||||
const zhRootReadmePath = path.join(testDir, 'README.zh-CN.md');
|
||||
const zhDocsReadmePath = path.join(testDir, 'docs', 'zh-CN', 'README.md');
|
||||
const zhAgentsPath = path.join(testDir, 'docs', 'zh-CN', 'AGENTS.md');
|
||||
const pluginJsonPath = path.join(testDir, '.claude-plugin', 'plugin.json');
|
||||
const marketplaceJsonPath = path.join(testDir, '.claude-plugin', 'marketplace.json');
|
||||
|
||||
fs.mkdirSync(path.join(testDir, 'agents'), { recursive: true });
|
||||
fs.mkdirSync(path.join(testDir, 'commands'), { recursive: true });
|
||||
fs.mkdirSync(path.join(testDir, 'skills', 'demo-skill'), { recursive: true });
|
||||
fs.mkdirSync(path.join(testDir, 'docs', 'zh-CN'), { recursive: true });
|
||||
fs.mkdirSync(path.join(testDir, '.claude-plugin'), { recursive: true });
|
||||
|
||||
fs.writeFileSync(path.join(testDir, 'agents', 'planner.md'), '---\nmodel: sonnet\ntools: Read\n---\n# Planner');
|
||||
fs.writeFileSync(path.join(testDir, 'commands', 'plan.md'), '---\ndescription: Plan\n---\n# Plan');
|
||||
fs.writeFileSync(path.join(testDir, 'skills', 'demo-skill', 'SKILL.md'), '---\nname: demo-skill\ndescription: Demo skill\norigin: ECC\n---\n# Demo Skill');
|
||||
|
||||
fs.writeFileSync(readmePath, `Access to ${readmeCounts.agents} agents, ${readmeCounts.skills} skills, and ${readmeCounts.commands} commands.\n| Feature | Claude Code | Cursor IDE | Codex CLI | OpenCode |\n|---------|------------|------------|-----------|----------|\n| Agents | PASS: ${readmeTableCounts.agents} agents | Shared | Shared | 1 |\n| Commands | PASS: ${readmeTableCounts.commands} commands | Shared | Shared | 1 |\n| Skills | PASS: ${readmeTableCounts.skills} skills | Shared | Shared | 1 |\n\n| Feature | Count | Format |\n|-----------|-------|---------|\n| Skills | ${readmeUnrelatedSkillsCount} | .agents/skills/ |\n\n## Cross-Tool Feature Parity\n\n| Feature | Claude Code | Cursor IDE | Codex CLI | OpenCode |\n|---------|------------|------------|-----------|----------|\n| **Agents** | ${readmeParityCounts.agents} | Shared (AGENTS.md) | Shared (AGENTS.md) | 12 |\n| **Commands** | ${readmeParityCounts.commands} | Shared | Instruction-based | 31 |\n| **Skills** | ${readmeParityCounts.skills} | Shared | 10 (native format) | 37 |\n`);
|
||||
fs.writeFileSync(readmePath, `Access to ${readmeCounts.agents} agents, ${readmeCounts.skills} skills, and ${readmeCounts.commands} commands.\n|-- agents/ # ${readmeProjectTreeAgents} specialized subagents for delegation\n| Feature | Claude Code | Cursor IDE | Codex CLI | OpenCode |\n|---------|------------|------------|-----------|----------|\n| Agents | PASS: ${readmeTableCounts.agents} agents | Shared | Shared | 1 |\n| Commands | PASS: ${readmeTableCounts.commands} commands | Shared | Shared | 1 |\n| Skills | PASS: ${readmeTableCounts.skills} skills | Shared | Shared | 1 |\n\n| Feature | Count | Format |\n|-----------|-------|---------|\n| Skills | ${readmeUnrelatedSkillsCount} | .agents/skills/ |\n\n## Cross-Tool Feature Parity\n\n| Feature | Claude Code | Cursor IDE | Codex CLI | OpenCode |\n|---------|------------|------------|-----------|----------|\n| **Agents** | ${readmeParityCounts.agents} | Shared (AGENTS.md) | Shared (AGENTS.md) | 12 |\n| **Commands** | ${readmeParityCounts.commands} | Shared | Instruction-based | 31 |\n| **Skills** | ${readmeParityCounts.skills} | Shared | 10 (native format) | 37 |\n`);
|
||||
fs.writeFileSync(agentsPath, `This is a **production-ready AI coding plugin** providing ${summaryCounts.agents} specialized agents, ${summaryCounts.skills} skills, ${summaryCounts.commands} commands, and automated hook workflows for software development.\n\n\`\`\`\n${structureLines.join('\n')}\n\`\`\`\n`);
|
||||
fs.writeFileSync(zhRootReadmePath, `**完成!** 你现在可以使用 ${zhRootReadmeCounts.agents} 个代理、${zhRootReadmeCounts.skills} 个技能和 ${zhRootReadmeCounts.commands} 个命令。\n`);
|
||||
fs.writeFileSync(zhDocsReadmePath, `**搞定!** 你现在可以使用 ${zhDocsReadmeCounts.agents} 个智能体、${zhDocsReadmeCounts.skills} 项技能和 ${zhDocsReadmeCounts.commands} 个命令了。\n| 功能特性 | Claude Code | OpenCode | 状态 |\n|---------|-------------|----------|--------|\n| 智能体 | \u2705 ${zhDocsTableCounts.agents} 个 | \u2705 12 个 | **Claude Code 领先** |\n| 命令 | \u2705 ${zhDocsTableCounts.commands} 个 | \u2705 31 个 | **Claude Code 领先** |\n| 技能 | \u2705 ${zhDocsTableCounts.skills} 项 | \u2705 37 项 | **Claude Code 领先** |\n\n| 功能特性 | 数量 | 格式 |\n|-----------|-------|---------|\n| 技能 | ${zhDocsUnrelatedSkillsCount} | .agents/skills/ |\n\n## 跨工具功能对等\n\n| 功能特性 | Claude Code | Cursor IDE | Codex CLI | OpenCode |\n|---------|------------|------------|-----------|----------|\n| **智能体** | ${zhDocsParityCounts.agents} | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 |\n| **命令** | ${zhDocsParityCounts.commands} | 共享 | 基于指令 | 31 |\n| **技能** | ${zhDocsParityCounts.skills} | 共享 | 10 (原生格式) | 37 |\n`);
|
||||
fs.writeFileSync(zhAgentsPath, `这是一个**生产就绪的 AI 编码插件**,提供 ${zhAgentsSummaryCounts.agents} 个专业代理、${zhAgentsSummaryCounts.skills} 项技能、${zhAgentsSummaryCounts.commands} 条命令以及自动化钩子工作流,用于软件开发。\n\n\`\`\`\n${zhAgentsStructureLines.join('\n')}\n\`\`\`\n`);
|
||||
fs.writeFileSync(pluginJsonPath, JSON.stringify({
|
||||
name: 'ecc',
|
||||
description: `Battle-tested plugin — ${pluginCounts.agents} agents, ${pluginCounts.skills} skills, ${pluginCounts.commands} legacy command shims`,
|
||||
}, null, 2));
|
||||
fs.writeFileSync(marketplaceJsonPath, JSON.stringify({
|
||||
plugins: [{
|
||||
name: 'ecc',
|
||||
description: `Marketplace plugin — ${marketplaceCounts.agents} agents, ${marketplaceCounts.skills} skills, ${marketplaceCounts.commands} legacy command shims`,
|
||||
}],
|
||||
}, null, 2));
|
||||
|
||||
return { readmePath, agentsPath, zhRootReadmePath, zhDocsReadmePath, zhAgentsPath };
|
||||
return { readmePath, agentsPath, zhRootReadmePath, zhDocsReadmePath, zhAgentsPath, pluginJsonPath, marketplaceJsonPath };
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
@@ -375,6 +435,8 @@ function runTests() {
|
||||
zhRootReadmePath,
|
||||
zhDocsReadmePath,
|
||||
zhAgentsPath,
|
||||
pluginJsonPath,
|
||||
marketplaceJsonPath,
|
||||
} = writeCatalogFixture(testDir, {
|
||||
readmeCounts: { agents: 99, skills: 99, commands: 99 },
|
||||
readmeTableCounts: { agents: 99, skills: 99, commands: 99 },
|
||||
@@ -404,6 +466,8 @@ function runTests() {
|
||||
README_ZH_CN_PATH: zhRootReadmePath,
|
||||
DOCS_ZH_CN_README_PATH: zhDocsReadmePath,
|
||||
DOCS_ZH_CN_AGENTS_PATH: zhAgentsPath,
|
||||
PLUGIN_JSON_PATH: pluginJsonPath,
|
||||
MARKETPLACE_JSON_PATH: marketplaceJsonPath,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.code, 1, 'Should fail when catalog counts drift');
|
||||
@@ -419,6 +483,8 @@ function runTests() {
|
||||
zhRootReadmePath,
|
||||
zhDocsReadmePath,
|
||||
zhAgentsPath,
|
||||
pluginJsonPath,
|
||||
marketplaceJsonPath,
|
||||
} = writeCatalogFixture(testDir, {
|
||||
readmeCounts: { agents: 1, skills: 1, commands: 1 },
|
||||
readmeTableCounts: { agents: 1, skills: 1, commands: 1 },
|
||||
@@ -433,6 +499,8 @@ function runTests() {
|
||||
README_ZH_CN_PATH: zhRootReadmePath,
|
||||
DOCS_ZH_CN_README_PATH: zhDocsReadmePath,
|
||||
DOCS_ZH_CN_AGENTS_PATH: zhAgentsPath,
|
||||
PLUGIN_JSON_PATH: pluginJsonPath,
|
||||
MARKETPLACE_JSON_PATH: marketplaceJsonPath,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.code, 1, 'Should fail when README parity table drifts');
|
||||
@@ -450,6 +518,8 @@ function runTests() {
|
||||
agentsPath,
|
||||
zhRootReadmePath,
|
||||
zhDocsReadmePath,
|
||||
pluginJsonPath,
|
||||
marketplaceJsonPath,
|
||||
} = writeCatalogFixture(testDir);
|
||||
const missingZhAgentsPath = path.join(testDir, 'docs', 'zh-CN', 'AGENTS.md');
|
||||
fs.rmSync(missingZhAgentsPath);
|
||||
@@ -461,6 +531,8 @@ function runTests() {
|
||||
README_ZH_CN_PATH: zhRootReadmePath,
|
||||
DOCS_ZH_CN_README_PATH: zhDocsReadmePath,
|
||||
DOCS_ZH_CN_AGENTS_PATH: missingZhAgentsPath,
|
||||
PLUGIN_JSON_PATH: pluginJsonPath,
|
||||
MARKETPLACE_JSON_PATH: marketplaceJsonPath,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.code, 1, 'Should fail when a tracked doc is missing');
|
||||
@@ -479,6 +551,8 @@ function runTests() {
|
||||
zhRootReadmePath,
|
||||
zhDocsReadmePath,
|
||||
zhAgentsPath,
|
||||
pluginJsonPath,
|
||||
marketplaceJsonPath,
|
||||
} = writeCatalogFixture(testDir, {
|
||||
readmeCounts: { agents: 9, skills: 9, commands: 9 },
|
||||
readmeTableCounts: { agents: 8, skills: 8, commands: 8 },
|
||||
@@ -489,6 +563,8 @@ function runTests() {
|
||||
zhDocsTableCounts: { agents: 12, skills: 12, commands: 12 },
|
||||
zhDocsParityCounts: { agents: 13, skills: 13, commands: 13 },
|
||||
zhAgentsSummaryCounts: { agents: 14, skills: 14, commands: 14 },
|
||||
pluginCounts: { agents: 18, skills: 18, commands: 18 },
|
||||
marketplaceCounts: { agents: 19, skills: 19, commands: 19 },
|
||||
zhAgentsStructureLines: [
|
||||
'agents/ — 15 个专业子代理',
|
||||
'skills/ — 16 个工作流技能和领域知识',
|
||||
@@ -504,6 +580,8 @@ function runTests() {
|
||||
README_ZH_CN_PATH: zhRootReadmePath,
|
||||
DOCS_ZH_CN_README_PATH: zhDocsReadmePath,
|
||||
DOCS_ZH_CN_AGENTS_PATH: zhAgentsPath,
|
||||
PLUGIN_JSON_PATH: pluginJsonPath,
|
||||
MARKETPLACE_JSON_PATH: marketplaceJsonPath,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.code, 0, `Should sync and pass, got stderr: ${result.stderr}`);
|
||||
@@ -513,8 +591,11 @@ function runTests() {
|
||||
const zhRootReadme = fs.readFileSync(zhRootReadmePath, 'utf8');
|
||||
const zhDocsReadme = fs.readFileSync(zhDocsReadmePath, 'utf8');
|
||||
const zhAgentsDoc = fs.readFileSync(zhAgentsPath, 'utf8');
|
||||
const pluginJson = fs.readFileSync(pluginJsonPath, 'utf8');
|
||||
const marketplaceJson = fs.readFileSync(marketplaceJsonPath, 'utf8');
|
||||
|
||||
assert.ok(readme.includes('Access to 1 agents, 1 skills, and 1 legacy command shims'), 'Should sync README quick-start summary');
|
||||
assert.ok(readme.includes('|-- agents/ # 1 specialized subagents for delegation'), 'Should sync README project tree agents count');
|
||||
assert.ok(readme.includes('| Agents | PASS: 1 agents |'), 'Should sync README comparison table');
|
||||
assert.ok(readme.includes('| Skills | 16 | .agents/skills/ |'), 'Should not rewrite unrelated README tables');
|
||||
assert.ok(readme.includes('| **Agents** | 1 | Shared (AGENTS.md) | Shared (AGENTS.md) | 12 |'), 'Should sync README parity table');
|
||||
@@ -527,6 +608,8 @@ function runTests() {
|
||||
assert.ok(zhDocsReadme.includes('| **智能体** | 1 | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 |'), 'Should sync docs/zh-CN/README parity table');
|
||||
assert.ok(zhAgentsDoc.includes('提供 1 个专业代理、1 项技能、1 条命令'), 'Should sync docs/zh-CN/AGENTS summary');
|
||||
assert.ok(zhAgentsDoc.includes('commands/ — 1 个斜杠命令'), 'Should sync docs/zh-CN/AGENTS structure');
|
||||
assert.ok(pluginJson.includes('1 agents, 1 skills, 1 legacy command shims'), 'Should sync plugin manifest catalog description');
|
||||
assert.ok(marketplaceJson.includes('1 agents, 1 skills, 1 legacy command shims'), 'Should sync marketplace plugin catalog description');
|
||||
|
||||
cleanupTestDir(testDir);
|
||||
})) passed++; else failed++;
|
||||
@@ -539,6 +622,8 @@ function runTests() {
|
||||
zhRootReadmePath,
|
||||
zhDocsReadmePath,
|
||||
zhAgentsPath,
|
||||
pluginJsonPath,
|
||||
marketplaceJsonPath,
|
||||
} = writeCatalogFixture(testDir, {
|
||||
structureLines: [
|
||||
' agents/ - 1 specialized subagents ',
|
||||
@@ -559,6 +644,8 @@ function runTests() {
|
||||
README_ZH_CN_PATH: zhRootReadmePath,
|
||||
DOCS_ZH_CN_README_PATH: zhDocsReadmePath,
|
||||
DOCS_ZH_CN_AGENTS_PATH: zhAgentsPath,
|
||||
PLUGIN_JSON_PATH: pluginJsonPath,
|
||||
MARKETPLACE_JSON_PATH: marketplaceJsonPath,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.code, 0, `Should accept formatting variations, got stderr: ${result.stderr}`);
|
||||
@@ -801,6 +888,181 @@ function runTests() {
|
||||
cleanupTestDir(testDir);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('warns when frontmatter is missing name (default mode)', () => {
|
||||
const testDir = createTestDir();
|
||||
const skillDir = path.join(testDir, 'no-name-skill');
|
||||
fs.mkdirSync(skillDir);
|
||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'),
|
||||
'---\ndescription: "X"\norigin: ECC\n---\n# Skill');
|
||||
|
||||
const result = runSkillsValidator(testDir);
|
||||
assert.strictEqual(result.code, 0,
|
||||
`Default mode must not fail CI; got stderr: ${result.stderr}`);
|
||||
assert.ok(
|
||||
result.stderr.includes('WARN') && result.stderr.includes('missing required field: name'),
|
||||
`Should warn on missing name; got stderr: ${result.stderr}`);
|
||||
cleanupTestDir(testDir);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('errors when frontmatter is missing name (strict mode)', () => {
|
||||
const testDir = createTestDir();
|
||||
const skillDir = path.join(testDir, 'no-name-skill');
|
||||
fs.mkdirSync(skillDir);
|
||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'),
|
||||
'---\ndescription: "X"\norigin: ECC\n---\n# Skill');
|
||||
|
||||
const result = runSkillsValidator(testDir, ['--strict']);
|
||||
assert.strictEqual(result.code, 1, '--strict must fail CI on missing name');
|
||||
assert.ok(result.stderr.includes('missing required field: name'),
|
||||
'Should report missing name');
|
||||
cleanupTestDir(testDir);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('warns on literal block-scalar description (|-)', () => {
|
||||
const testDir = createTestDir();
|
||||
const skillDir = path.join(testDir, 'block-desc-skill');
|
||||
fs.mkdirSync(skillDir);
|
||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'),
|
||||
'---\nname: block-desc-skill\ndescription: |-\n line one\n line two\norigin: ECC\n---\n# Skill');
|
||||
|
||||
const result = runSkillsValidator(testDir);
|
||||
assert.strictEqual(result.code, 0, 'Default mode should not fail CI');
|
||||
assert.ok(
|
||||
result.stderr.includes('WARN') && result.stderr.includes('literal block scalar'),
|
||||
`Should warn on |- description; got stderr: ${result.stderr}`);
|
||||
cleanupTestDir(testDir);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('accepts folded (>) and inline descriptions', () => {
|
||||
const testDir = createTestDir();
|
||||
const folded = path.join(testDir, 'folded-skill');
|
||||
fs.mkdirSync(folded);
|
||||
fs.writeFileSync(path.join(folded, 'SKILL.md'),
|
||||
'---\nname: folded-skill\ndescription: >\n joined\n on spaces\norigin: ECC\n---\n# Skill');
|
||||
const inline = path.join(testDir, 'inline-skill');
|
||||
fs.mkdirSync(inline);
|
||||
fs.writeFileSync(path.join(inline, 'SKILL.md'),
|
||||
'---\nname: inline-skill\ndescription: "single line"\norigin: ECC\n---\n# Skill');
|
||||
|
||||
const result = runSkillsValidator(testDir, ['--strict']);
|
||||
assert.strictEqual(result.code, 0,
|
||||
`Folded and inline should pass strict; got stderr: ${result.stderr}`);
|
||||
assert.ok(result.stdout.includes('Validated 2'),
|
||||
`Should count both skills; got stdout: ${result.stdout}`);
|
||||
cleanupTestDir(testDir);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('skips hidden directories under skills/', () => {
|
||||
const testDir = createTestDir();
|
||||
// A dot-prefixed directory (e.g. .DS_Store-adjacent junk or legacy
|
||||
// cache) must not count as a skill and must not error.
|
||||
fs.mkdirSync(path.join(testDir, '.cache'));
|
||||
fs.writeFileSync(path.join(testDir, '.cache', 'SKILL.md'), '# ignored');
|
||||
const real = path.join(testDir, 'real-skill');
|
||||
fs.mkdirSync(real);
|
||||
fs.writeFileSync(path.join(real, 'SKILL.md'),
|
||||
'---\nname: real-skill\ndescription: "x"\norigin: ECC\n---\n# Skill');
|
||||
|
||||
const result = runSkillsValidator(testDir, ['--strict']);
|
||||
assert.strictEqual(result.code, 0, 'Hidden dirs should be skipped');
|
||||
assert.ok(result.stdout.includes('Validated 1'),
|
||||
`Should only count the non-hidden skill; got stdout: ${result.stdout}`);
|
||||
cleanupTestDir(testDir);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('warns when name: value is empty or whitespace', () => {
|
||||
const testDir = createTestDir();
|
||||
const skillDir = path.join(testDir, 'empty-name-skill');
|
||||
fs.mkdirSync(skillDir);
|
||||
// `name:` key present but value is blank.
|
||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'),
|
||||
'---\nname: \ndescription: "X"\norigin: ECC\n---\n# Skill');
|
||||
|
||||
const result = runSkillsValidator(testDir);
|
||||
assert.strictEqual(result.code, 0,
|
||||
`Default mode must not fail CI; got stderr: ${result.stderr}`);
|
||||
assert.ok(
|
||||
result.stderr.includes('WARN') && result.stderr.includes("'name' is empty"),
|
||||
`Should warn on empty name; got stderr: ${result.stderr}`);
|
||||
cleanupTestDir(testDir);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('warns on literal block-scalar description with |+ chomp', () => {
|
||||
const testDir = createTestDir();
|
||||
const skillDir = path.join(testDir, 'keep-desc-skill');
|
||||
fs.mkdirSync(skillDir);
|
||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'),
|
||||
'---\nname: keep-desc-skill\ndescription: |+\n line one\n line two\norigin: ECC\n---\n# Skill');
|
||||
|
||||
const result = runSkillsValidator(testDir);
|
||||
assert.strictEqual(result.code, 0, 'Default mode should not fail CI');
|
||||
assert.ok(result.stderr.includes('literal block scalar'),
|
||||
`Should warn on |+ description; got stderr: ${result.stderr}`);
|
||||
cleanupTestDir(testDir);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('warns on block-scalar description with indent indicator and trailing comment', () => {
|
||||
const testDir = createTestDir();
|
||||
const skillDir = path.join(testDir, 'indent-desc-skill');
|
||||
fs.mkdirSync(skillDir);
|
||||
// `|-2 # note` is still a literal block scalar in YAML 1.2.
|
||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'),
|
||||
'---\nname: indent-desc-skill\ndescription: |-2 # trimmed two-space indent\n line one\n line two\norigin: ECC\n---\n# Skill');
|
||||
|
||||
const result = runSkillsValidator(testDir);
|
||||
assert.strictEqual(result.code, 0, 'Default mode should not fail CI');
|
||||
assert.ok(result.stderr.includes('literal block scalar'),
|
||||
`Should warn on |-2 description; got stderr: ${result.stderr}`);
|
||||
cleanupTestDir(testDir);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('honors CI_STRICT_SKILLS=1 env flag', () => {
|
||||
const testDir = createTestDir();
|
||||
const skillDir = path.join(testDir, 'no-name-skill-env');
|
||||
fs.mkdirSync(skillDir);
|
||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'),
|
||||
'---\ndescription: "X"\norigin: ECC\n---\n# Skill');
|
||||
|
||||
const result = runSkillsValidator(testDir, [], { CI_STRICT_SKILLS: '1' });
|
||||
assert.strictEqual(result.code, 1, 'CI_STRICT_SKILLS=1 must fail CI on missing name');
|
||||
assert.ok(result.stderr.includes('missing required field: name'),
|
||||
'Should report missing name');
|
||||
cleanupTestDir(testDir);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('flags comment-only name value as empty (strict)', () => {
|
||||
const testDir = createTestDir();
|
||||
const skillDir = path.join(testDir, 'comment-only-name');
|
||||
fs.mkdirSync(skillDir);
|
||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'),
|
||||
'---\nname: # todo\ndescription: "X"\norigin: ECC\n---\n# Skill');
|
||||
|
||||
const result = runSkillsValidator(testDir, ['--strict']);
|
||||
assert.strictEqual(result.code, 1, 'Strict mode must fail CI on empty name');
|
||||
assert.ok(result.stderr.includes("'name' is empty"),
|
||||
`Should report empty name; got stderr: ${result.stderr}`);
|
||||
cleanupTestDir(testDir);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('tolerates ---trailing text outside frontmatter block', () => {
|
||||
// A SKILL.md whose body contains a line starting with '---text'
|
||||
// must not be parsed as frontmatter. Regression guard for
|
||||
// closing-delimiter tightening: the old regex would greedily
|
||||
// match '---trailing'.
|
||||
const testDir = createTestDir();
|
||||
const skillDir = path.join(testDir, 'no-frontmatter-dashes');
|
||||
fs.mkdirSync(skillDir);
|
||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'),
|
||||
'# Skill\n\nSome body text.\n\n---trailing content\nmore body\n');
|
||||
|
||||
const result = runSkillsValidator(testDir, ['--strict']);
|
||||
assert.strictEqual(result.code, 0,
|
||||
`Should not flag frontmatter findings when no valid frontmatter exists; got stderr: ${result.stderr}`);
|
||||
assert.ok(!result.stderr.includes('missing required field: name'),
|
||||
'Must not treat ---trailing as a frontmatter closer');
|
||||
cleanupTestDir(testDir);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ==========================================
|
||||
// validate-commands.js
|
||||
// ==========================================
|
||||
|
||||
@@ -35,12 +35,12 @@ console.log('\n=== Testing public install identifiers ===\n');
|
||||
for (const relativePath of publicInstallDocs) {
|
||||
const content = fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
|
||||
|
||||
test(`${relativePath} does not use the stale ecc@ecc plugin identifier`, () => {
|
||||
assert.ok(!content.includes('ecc@ecc'));
|
||||
test(`${relativePath} does not use the overlong legacy marketplace plugin identifier`, () => {
|
||||
assert.ok(!content.includes('everything-claude-code@everything-claude-code'));
|
||||
});
|
||||
|
||||
test(`${relativePath} documents the canonical marketplace plugin identifier`, () => {
|
||||
assert.ok(content.includes('everything-claude-code@everything-claude-code'));
|
||||
test(`${relativePath} documents the short marketplace plugin identifier`, () => {
|
||||
assert.ok(content.includes('ecc@ecc'));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -86,12 +86,12 @@ for (const relativePath of publicCommandNamespaceDocs) {
|
||||
|
||||
test(`${relativePath} uses the canonical plugin command namespace`, () => {
|
||||
assert.ok(
|
||||
!content.includes('/ecc:'),
|
||||
'Expected docs not to advertise the unsupported /ecc: plugin alias'
|
||||
!content.includes('/everything-claude-code:'),
|
||||
'Expected docs not to advertise the overlong legacy plugin command namespace'
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('/everything-claude-code:plan'),
|
||||
'Expected docs to show the canonical plugin command namespace'
|
||||
content.includes('/ecc:plan'),
|
||||
'Expected docs to show the short plugin command namespace'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -408,7 +408,104 @@ function runTests() {
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Test 10: MultiEdit gates first unchecked file ---
|
||||
// --- Test 10: respects direct GateGuard env disable for recovery sessions ---
|
||||
clearState();
|
||||
if (test('respects ECC_GATEGUARD=off without writing gate state', () => {
|
||||
const input = {
|
||||
tool_name: 'Write',
|
||||
tool_input: { file_path: '/src/env-disabled.js', content: 'export const ok = true;' }
|
||||
};
|
||||
const result = runHook(input, { ECC_GATEGUARD: 'off' });
|
||||
const output = parseOutput(result.stdout);
|
||||
|
||||
assert.ok(output, 'should produce valid JSON output');
|
||||
assert.strictEqual(output.tool_name, 'Write', 'disabled gate should pass through raw input');
|
||||
assert.ok(!output.hookSpecificOutput, 'disabled gate should not deny the operation');
|
||||
assert.ok(!fs.existsSync(stateFile), 'disabled gate should not create or mutate gate state');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Test 11: respects legacy GATEGUARD_DISABLED env disable ---
|
||||
clearState();
|
||||
if (test('respects GATEGUARD_DISABLED=1 for Bash recovery', () => {
|
||||
const input = {
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'npm test' }
|
||||
};
|
||||
const result = runBashHook(input, { GATEGUARD_DISABLED: '1' });
|
||||
const output = parseOutput(result.stdout);
|
||||
|
||||
assert.ok(output, 'should produce valid JSON output');
|
||||
assert.strictEqual(output.tool_name, 'Bash', 'disabled gate should pass Bash through raw input');
|
||||
assert.ok(!output.hookSpecificOutput, 'disabled gate should not deny Bash');
|
||||
assert.ok(!fs.existsSync(stateFile), 'disabled gate should not create or mutate gate state');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Test 12: legacy GATEGUARD_DISABLED compatibility is scoped to =1 ---
|
||||
clearState();
|
||||
if (test('does not treat GATEGUARD_DISABLED=true as a disable flag', () => {
|
||||
const input = {
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'npm test' }
|
||||
};
|
||||
const result = runBashHook(input, { GATEGUARD_DISABLED: 'true' });
|
||||
const output = parseOutput(result.stdout);
|
||||
|
||||
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
|
||||
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('current user request'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Test 13: denial messages show an escape hatch ---
|
||||
clearState();
|
||||
if (test('denial messages include direct recovery escape hatch', () => {
|
||||
const input = {
|
||||
tool_name: 'Write',
|
||||
tool_input: { file_path: '/src/recovery-hint.js', content: 'export const ok = true;' }
|
||||
};
|
||||
const result = runHook(input);
|
||||
const output = parseOutput(result.stdout);
|
||||
|
||||
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
|
||||
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('ECC_GATEGUARD=off'),
|
||||
'denial reason should show the direct recovery env toggle');
|
||||
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('ECC_DISABLED_HOOKS'),
|
||||
'denial reason should mention the existing hook-id disable control');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Test 14: routine Bash denial messages show the Bash hook escape hatch ---
|
||||
clearState();
|
||||
if (test('routine Bash denials include Bash hook disable id', () => {
|
||||
const input = {
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'npm test' }
|
||||
};
|
||||
const result = runBashHook(input);
|
||||
const output = parseOutput(result.stdout);
|
||||
const reason = output.hookSpecificOutput.permissionDecisionReason;
|
||||
|
||||
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
|
||||
assert.ok(reason.includes('pre:bash:gateguard-fact-force'),
|
||||
'routine Bash denial should show the Bash hook ID');
|
||||
assert.ok(!reason.includes('pre:edit-write:gateguard-fact-force'),
|
||||
'routine Bash denial should not show the Edit/Write hook ID as the targeted disable');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Test 15: destructive Bash denials do not advertise the recovery escape hatch ---
|
||||
clearState();
|
||||
if (test('destructive Bash denials omit recovery escape hatch', () => {
|
||||
const input = {
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'rm -rf /tmp/demo' }
|
||||
};
|
||||
const result = runBashHook(input);
|
||||
const output = parseOutput(result.stdout);
|
||||
|
||||
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
|
||||
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('Destructive command detected'));
|
||||
assert.ok(!output.hookSpecificOutput.permissionDecisionReason.includes('ECC_GATEGUARD=off'),
|
||||
'destructive gate should not advertise disabling GateGuard');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Test 16: MultiEdit gates first unchecked file ---
|
||||
clearState();
|
||||
if (test('denies first MultiEdit with unchecked file', () => {
|
||||
const input = {
|
||||
@@ -884,6 +981,168 @@ function runTests() {
|
||||
assert.ok(fs.existsSync(freshState), 'fresh state file should remain');
|
||||
})) passed++; else failed++;
|
||||
|
||||
function runFreshSessionEdit(filePath, extra = {}) {
|
||||
return runHook({
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: filePath, old_string: 'a', new_string: 'b' },
|
||||
session_id: 'subagent-fresh-session',
|
||||
...extra
|
||||
}, { CLAUDE_SESSION_ID: '', ECC_SESSION_ID: '' });
|
||||
}
|
||||
|
||||
function runFreshSessionBash(command, extra = {}) {
|
||||
return runBashHook({
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command },
|
||||
session_id: 'subagent-fresh-session',
|
||||
...extra
|
||||
}, { CLAUDE_SESSION_ID: '', ECC_SESSION_ID: '' });
|
||||
}
|
||||
|
||||
// --- Test 30: top-level Edit denies; subagent Edit allows ---
|
||||
clearState();
|
||||
if (test('A/B: same Edit denies at top level and allows with agent_id', () => {
|
||||
const topLevel = runFreshSessionEdit('/src/subagent-edit.js');
|
||||
const topOut = parseOutput(topLevel.stdout);
|
||||
assert.ok(topOut, 'top-level edit should produce JSON output');
|
||||
assert.strictEqual(topOut.hookSpecificOutput.permissionDecision, 'deny');
|
||||
|
||||
clearState();
|
||||
const subagent = runFreshSessionEdit('/src/subagent-edit.js', { agent_id: 'agent-abc-123' });
|
||||
const subOut = parseOutput(subagent.stdout);
|
||||
assert.ok(subOut, 'subagent edit should produce JSON output');
|
||||
assert.ok(!subOut.hookSpecificOutput || subOut.hookSpecificOutput.permissionDecision !== 'deny',
|
||||
'subagent edit should bypass the first-touch file gate');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Test 31: top-level Write denies; subagent Write allows ---
|
||||
clearState();
|
||||
if (test('A/B: same Write denies at top level and allows with agent_id', () => {
|
||||
const topLevel = runHook({
|
||||
tool_name: 'Write',
|
||||
tool_input: { file_path: '/src/subagent-write.js', content: 'module.exports = {};' },
|
||||
session_id: 'subagent-fresh-session'
|
||||
}, { CLAUDE_SESSION_ID: '', ECC_SESSION_ID: '' });
|
||||
const topOut = parseOutput(topLevel.stdout);
|
||||
assert.ok(topOut, 'top-level write should produce JSON output');
|
||||
assert.strictEqual(topOut.hookSpecificOutput.permissionDecision, 'deny');
|
||||
|
||||
clearState();
|
||||
const subagent = runHook({
|
||||
tool_name: 'Write',
|
||||
tool_input: { file_path: '/src/subagent-write.js', content: 'module.exports = {};' },
|
||||
session_id: 'subagent-fresh-session',
|
||||
agent_id: 'agent-abc-123'
|
||||
}, { CLAUDE_SESSION_ID: '', ECC_SESSION_ID: '' });
|
||||
const subOut = parseOutput(subagent.stdout);
|
||||
assert.ok(subOut, 'subagent write should produce JSON output');
|
||||
assert.ok(!subOut.hookSpecificOutput || subOut.hookSpecificOutput.permissionDecision !== 'deny',
|
||||
'subagent write should bypass the first-touch file gate');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Test 32: top-level MultiEdit denies; subagent MultiEdit allows ---
|
||||
clearState();
|
||||
if (test('A/B: same MultiEdit denies at top level and allows with agent_id', () => {
|
||||
const edits = [
|
||||
{ file_path: '/src/subagent-multi-a.js', old_string: 'a', new_string: 'b' },
|
||||
{ file_path: '/src/subagent-multi-b.js', old_string: 'c', new_string: 'd' }
|
||||
];
|
||||
|
||||
const topLevel = runHook({
|
||||
tool_name: 'MultiEdit',
|
||||
tool_input: { edits },
|
||||
session_id: 'subagent-fresh-session'
|
||||
}, { CLAUDE_SESSION_ID: '', ECC_SESSION_ID: '' });
|
||||
const topOut = parseOutput(topLevel.stdout);
|
||||
assert.ok(topOut, 'top-level MultiEdit should produce JSON output');
|
||||
assert.strictEqual(topOut.hookSpecificOutput.permissionDecision, 'deny');
|
||||
|
||||
clearState();
|
||||
const subagent = runHook({
|
||||
tool_name: 'MultiEdit',
|
||||
tool_input: { edits },
|
||||
session_id: 'subagent-fresh-session',
|
||||
agent_id: 'agent-abc-123'
|
||||
}, { CLAUDE_SESSION_ID: '', ECC_SESSION_ID: '' });
|
||||
const subOut = parseOutput(subagent.stdout);
|
||||
assert.ok(subOut, 'subagent MultiEdit should produce JSON output');
|
||||
assert.ok(!subOut.hookSpecificOutput || subOut.hookSpecificOutput.permissionDecision !== 'deny',
|
||||
'subagent MultiEdit should bypass the first-touch file gate');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Test 33: Bash stays gated inside subagents ---
|
||||
clearState();
|
||||
if (test('routine Bash remains gated in subagent context', () => {
|
||||
const result = runFreshSessionBash('pwd', { agent_id: 'agent-abc-123' });
|
||||
const output = parseOutput(result.stdout);
|
||||
assert.ok(output, 'subagent Bash should produce JSON output');
|
||||
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
|
||||
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('current user request'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Test 34: destructive Bash stays gated inside subagents ---
|
||||
clearState();
|
||||
if (test('destructive Bash remains gated in subagent context', () => {
|
||||
const result = runFreshSessionBash('rm -rf /tmp/demo-path', { agent_id: 'agent-abc-123' });
|
||||
const output = parseOutput(result.stdout);
|
||||
assert.ok(output, 'subagent destructive Bash should produce JSON output');
|
||||
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
|
||||
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('Destructive command detected'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Test 35: parent tool IDs also mark subagent context ---
|
||||
clearState();
|
||||
if (test('parent_tool_use_id and parentToolUseId mark subagent file edits', () => {
|
||||
const snake = runFreshSessionEdit('/src/subagent-parent-snake.js', { parent_tool_use_id: 'toolu_parent_01' });
|
||||
const snakeOut = parseOutput(snake.stdout);
|
||||
assert.ok(snakeOut, 'snake-case parent marker should produce JSON output');
|
||||
assert.ok(!snakeOut.hookSpecificOutput || snakeOut.hookSpecificOutput.permissionDecision !== 'deny',
|
||||
'parent_tool_use_id should bypass the first-touch file gate');
|
||||
|
||||
clearState();
|
||||
const camel = runFreshSessionEdit('/src/subagent-parent-camel.js', { parentToolUseId: 'toolu_parent_02' });
|
||||
const camelOut = parseOutput(camel.stdout);
|
||||
assert.ok(camelOut, 'camel-case parent marker should produce JSON output');
|
||||
assert.ok(!camelOut.hookSpecificOutput || camelOut.hookSpecificOutput.permissionDecision !== 'deny',
|
||||
'parentToolUseId should bypass the first-touch file gate');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Test 36: only non-empty string markers count ---
|
||||
clearState();
|
||||
if (test('empty and non-string subagent markers do not bypass file gates', () => {
|
||||
const cases = [
|
||||
['empty', { agent_id: '' }],
|
||||
['whitespace', { agent_id: ' ' }],
|
||||
['numeric', { agent_id: 12345 }],
|
||||
['null', { agent_id: null }]
|
||||
];
|
||||
|
||||
for (const [name, extra] of cases) {
|
||||
clearState();
|
||||
const result = runFreshSessionEdit(`/src/subagent-marker-${name}.js`, extra);
|
||||
const output = parseOutput(result.stdout);
|
||||
assert.ok(output, `${name} marker should produce JSON output`);
|
||||
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny',
|
||||
`${name} marker should not bypass the first-touch file gate`);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// --- Test 37: two sequential subagent Edits on different files pass ---
|
||||
clearState();
|
||||
if (test('two sequential subagent Edits on different files both pass', () => {
|
||||
const first = runFreshSessionEdit('/src/subagent-seq-a.js', { agent_id: 'agent-seq' });
|
||||
const firstOut = parseOutput(first.stdout);
|
||||
assert.ok(firstOut, 'first subagent edit should produce JSON output');
|
||||
assert.ok(!firstOut.hookSpecificOutput || firstOut.hookSpecificOutput.permissionDecision !== 'deny',
|
||||
'first subagent edit should pass');
|
||||
|
||||
const second = runFreshSessionEdit('/src/subagent-seq-b.js', { agent_id: 'agent-seq' });
|
||||
const secondOut = parseOutput(second.stdout);
|
||||
assert.ok(secondOut, 'second subagent edit should produce JSON output');
|
||||
assert.ok(!secondOut.hookSpecificOutput || secondOut.hookSpecificOutput.permissionDecision !== 'deny',
|
||||
'second subagent edit should pass even on a new file');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Cleanup only the temp directory created by this test file.
|
||||
try {
|
||||
if (fs.existsSync(stateDir)) {
|
||||
|
||||
+109
-5
@@ -248,7 +248,7 @@ function withPrependedPath(binDir, env = {}) {
|
||||
}
|
||||
|
||||
function assertNoProjectDetectionSideEffects(homeDir, testName) {
|
||||
const homunculusDir = path.join(homeDir, '.claude', 'homunculus');
|
||||
const homunculusDir = path.join(homeDir, '.local', 'share', 'ecc-homunculus');
|
||||
const registryPath = path.join(homunculusDir, 'projects.json');
|
||||
const projectsDir = path.join(homunculusDir, 'projects');
|
||||
|
||||
@@ -1178,6 +1178,47 @@ async function runTests() {
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
await asyncTest('reads session_id from stdin JSON (Claude Code wire format)', async () => {
|
||||
const sessionId = 'test-stdin-' + Date.now();
|
||||
const stdinJson = JSON.stringify({ session_id: sessionId, tool_name: 'Edit' });
|
||||
|
||||
const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), stdinJson, {});
|
||||
assert.strictEqual(result.code, 0, `Exit code should be 0, got ${result.code}`);
|
||||
|
||||
const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`);
|
||||
assert.ok(fs.existsSync(counterFile), `Counter file should be created from stdin session_id at ${counterFile}`);
|
||||
const count = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10);
|
||||
assert.strictEqual(count, 1, `Counter should be 1, got ${count}`);
|
||||
|
||||
fs.unlinkSync(counterFile);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
await asyncTest('stdin session_id takes precedence over env CLAUDE_SESSION_ID', async () => {
|
||||
const stdinSession = 'stdin-wins-' + Date.now();
|
||||
const envSession = 'env-loses-' + Date.now();
|
||||
const stdinJson = JSON.stringify({ session_id: stdinSession });
|
||||
|
||||
const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), stdinJson, {
|
||||
CLAUDE_SESSION_ID: envSession
|
||||
});
|
||||
assert.strictEqual(result.code, 0);
|
||||
|
||||
const stdinCounter = path.join(os.tmpdir(), `claude-tool-count-${stdinSession}`);
|
||||
const envCounter = path.join(os.tmpdir(), `claude-tool-count-${envSession}`);
|
||||
assert.ok(fs.existsSync(stdinCounter), 'Stdin session counter must exist');
|
||||
assert.ok(!fs.existsSync(envCounter), 'Env session counter must NOT exist when stdin provides session_id');
|
||||
|
||||
fs.unlinkSync(stdinCounter);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// evaluate-session.js tests
|
||||
console.log('\nevaluate-session.js:');
|
||||
|
||||
@@ -2691,6 +2732,68 @@ async function runTests() {
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
await asyncTest('blocks Windows shell metacharacters before shell:true formatter execution', async () => {
|
||||
const hookPath = path.join(scriptsDir, 'post-edit-format.js');
|
||||
const resolverPath = path.join(scriptsDir, '..', 'lib', 'resolve-formatter.js');
|
||||
const childProcess = require('child_process');
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
const originalSpawnSync = childProcess.spawnSync;
|
||||
const originalExecFileSync = childProcess.execFileSync;
|
||||
const resolvedResolverPath = require.resolve(resolverPath);
|
||||
const resolvedHookPath = require.resolve(hookPath);
|
||||
const originalResolverCache = require.cache[resolvedResolverPath];
|
||||
const originalHookCache = require.cache[resolvedHookPath];
|
||||
const blockedPaths = ['semicolon;test.js', 'backtick`test.js', 'subshell$(test).js', 'group(test).js'];
|
||||
|
||||
try {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
||||
|
||||
let spawnCalls = [];
|
||||
childProcess.spawnSync = (...args) => {
|
||||
spawnCalls.push(args);
|
||||
return { status: 0, stderr: Buffer.from('') };
|
||||
};
|
||||
childProcess.execFileSync = () => {
|
||||
throw new Error('execFileSync should not run for Windows .cmd formatter shims');
|
||||
};
|
||||
|
||||
require.cache[resolvedResolverPath] = {
|
||||
id: resolvedResolverPath,
|
||||
filename: resolvedResolverPath,
|
||||
loaded: true,
|
||||
exports: {
|
||||
findProjectRoot: () => process.cwd(),
|
||||
detectFormatter: () => 'prettier',
|
||||
resolveFormatterBin: () => ({ bin: 'formatter.cmd', prefix: [] })
|
||||
}
|
||||
};
|
||||
delete require.cache[resolvedHookPath];
|
||||
|
||||
const { run } = require(hookPath);
|
||||
|
||||
for (const filePath of blockedPaths) {
|
||||
spawnCalls = [];
|
||||
const stdinJson = JSON.stringify({ tool_input: { file_path: filePath } });
|
||||
assert.strictEqual(run(stdinJson), stdinJson, 'Should pass through original stdin JSON');
|
||||
assert.strictEqual(spawnCalls.length, 0, `Should reject ${filePath} before spawnSync`);
|
||||
}
|
||||
} finally {
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform);
|
||||
}
|
||||
childProcess.spawnSync = originalSpawnSync;
|
||||
childProcess.execFileSync = originalExecFileSync;
|
||||
if (originalResolverCache) require.cache[resolvedResolverPath] = originalResolverCache;
|
||||
else delete require.cache[resolvedResolverPath];
|
||||
if (originalHookCache) require.cache[resolvedHookPath] = originalHookCache;
|
||||
else delete require.cache[resolvedHookPath];
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
await asyncTest('matches .tsx extension for formatting', async () => {
|
||||
const stdinJson = JSON.stringify({ tool_input: { file_path: '/nonexistent/component.tsx' } });
|
||||
@@ -2844,11 +2947,12 @@ async function runTests() {
|
||||
assert.strictEqual(code, 0, `detect-project should source cleanly, stderr: ${stderr}`);
|
||||
|
||||
const [projectId, projectDir] = stdout.trim().split(/\r?\n/);
|
||||
const registryPath = path.join(homeDir, '.claude', 'homunculus', 'projects.json');
|
||||
const registryPath = path.join(homeDir, '.local', 'share', 'ecc-homunculus', 'projects.json');
|
||||
const expectedProjectDir = path.join(
|
||||
homeDir,
|
||||
'.claude',
|
||||
'homunculus',
|
||||
'.local',
|
||||
'share',
|
||||
'ecc-homunculus',
|
||||
'projects',
|
||||
projectId
|
||||
);
|
||||
@@ -2922,7 +3026,7 @@ async function runTests() {
|
||||
|
||||
assert.strictEqual(result.code, 0, `observe.sh should exit successfully, stderr: ${result.stderr}`);
|
||||
|
||||
const projectsDir = path.join(homeDir, '.claude', 'homunculus', 'projects');
|
||||
const projectsDir = path.join(homeDir, '.local', 'share', 'ecc-homunculus', 'projects');
|
||||
const projectIds = fs.readdirSync(projectsDir);
|
||||
assert.strictEqual(projectIds.length, 1, 'observe.sh should create one project-scoped observation directory');
|
||||
|
||||
|
||||
@@ -952,6 +952,103 @@ async function runTests() {
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Windows-only: child_process.spawn cannot resolve .cmd/.bat shims for
|
||||
// bare PATH commands without an extension, and Node 18.20+/20.12+ refuse
|
||||
// to spawn .cmd targets without `shell: true` (CVE-2024-27980). The probe
|
||||
// must retry bare command names with platform extensions and route .cmd/.bat
|
||||
// through the shell, otherwise tools like `npx` are misclassified as
|
||||
// unhealthy on first use. Path-like commands keep single-candidate ENOENT
|
||||
// semantics.
|
||||
if (process.platform === 'win32') {
|
||||
if (await asyncTest('windows: probes bare PATH commands via .cmd fallback', async () => {
|
||||
const tempDir = createTempDir();
|
||||
const binDir = path.join(tempDir, 'bin');
|
||||
const configPath = path.join(tempDir, 'claude.json');
|
||||
const statePath = path.join(tempDir, 'mcp-health.json');
|
||||
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(binDir, 'winfallback.cmd'),
|
||||
['@echo off', 'node -e "setInterval(()=>{},1000)"', ''].join('\r\n')
|
||||
);
|
||||
|
||||
try {
|
||||
writeConfig(configPath, {
|
||||
mcpServers: {
|
||||
winfallback: {
|
||||
command: 'winfallback',
|
||||
args: []
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const input = { tool_name: 'mcp__winfallback__list', tool_input: {} };
|
||||
const result = runHook(input, {
|
||||
CLAUDE_HOOK_EVENT_NAME: 'PreToolUse',
|
||||
ECC_MCP_CONFIG_PATH: configPath,
|
||||
ECC_MCP_HEALTH_STATE_PATH: statePath,
|
||||
ECC_MCP_HEALTH_TIMEOUT_MS: '500',
|
||||
PATH: `${binDir}${path.delimiter}${process.env.PATH || ''}`
|
||||
});
|
||||
|
||||
assert.strictEqual(
|
||||
result.code,
|
||||
0,
|
||||
`Expected bare command to be probed via .cmd fallback: ${hookFailureDetails(result, statePath)}`
|
||||
);
|
||||
|
||||
const state = readState(statePath);
|
||||
assert.strictEqual(
|
||||
state.servers.winfallback.status,
|
||||
'healthy',
|
||||
'Expected bare command to be marked healthy via .cmd fallback'
|
||||
);
|
||||
} finally {
|
||||
cleanupTempDir(tempDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
} else {
|
||||
console.log(' - skipped: windows: probes bare PATH commands via .cmd fallback (non-Windows)');
|
||||
}
|
||||
|
||||
if (await asyncTest('probes command servers using non-absolute commands (e.g. npx) via PATH resolution', async () => {
|
||||
const tempDir = createTempDir();
|
||||
const configPath = path.join(tempDir, 'claude.json');
|
||||
const statePath = path.join(tempDir, 'mcp-health.json');
|
||||
const serverScript = path.join(tempDir, 'shell-server.js');
|
||||
|
||||
try {
|
||||
// Create a server script that stays alive
|
||||
fs.writeFileSync(serverScript, "setInterval(() => {}, 1000);\n");
|
||||
|
||||
// Use 'node' (non-absolute) as the command to exercise PATH-based
|
||||
// resolution without depending on npx being available in the environment.
|
||||
writeConfig(configPath, {
|
||||
mcpServers: {
|
||||
shelltest: {
|
||||
command: 'node',
|
||||
args: [serverScript]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const input = { tool_name: 'mcp__shelltest__ping', tool_input: {} };
|
||||
const result = runHook(input, {
|
||||
CLAUDE_HOOK_EVENT_NAME: 'PreToolUse',
|
||||
ECC_MCP_CONFIG_PATH: configPath,
|
||||
ECC_MCP_HEALTH_STATE_PATH: statePath,
|
||||
ECC_MCP_HEALTH_TIMEOUT_MS: '100'
|
||||
});
|
||||
|
||||
assert.strictEqual(result.code, 0, `Expected non-absolute command to resolve via PATH, got ${result.code}`);
|
||||
|
||||
const state = readState(statePath);
|
||||
assert.strictEqual(state.servers.shelltest.status, 'healthy', 'Expected PATH-resolved server to be marked healthy');
|
||||
} finally {
|
||||
cleanupTempDir(tempDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ function runObserve({ homeDir, cwd }) {
|
||||
}
|
||||
|
||||
function readSingleProjectMetadata(homeDir) {
|
||||
const projectsDir = path.join(homeDir, '.claude', 'homunculus', 'projects');
|
||||
const projectsDir = path.join(homeDir, '.local', 'share', 'ecc-homunculus', 'projects');
|
||||
const projectIds = fs.readdirSync(projectsDir);
|
||||
assert.strictEqual(projectIds.length, 1, 'Expected exactly one project directory');
|
||||
const projectDir = path.join(projectsDir, projectIds[0]);
|
||||
|
||||
@@ -96,7 +96,8 @@ test('observer-loop.sh defines ANALYZING guard variable', () => {
|
||||
test('on_usr1 checks ANALYZING before starting analysis', () => {
|
||||
const content = fs.readFileSync(observerLoopPath, 'utf8');
|
||||
assert.ok(content.includes('if [ "$ANALYZING" -eq 1 ]'), 'on_usr1 should check ANALYZING flag');
|
||||
assert.ok(content.includes('Analysis already in progress, skipping signal'), 'on_usr1 should log when skipping due to re-entrancy');
|
||||
assert.ok(content.includes('Analysis already in progress, deferring signal'), 'on_usr1 should log when deferring due to re-entrancy');
|
||||
assert.ok(content.includes('PENDING_ANALYSIS=1'), 'on_usr1 should preserve re-entrant nudges for the next loop iteration');
|
||||
});
|
||||
|
||||
test('on_usr1 sets ANALYZING=1 before and ANALYZING=0 after analysis', () => {
|
||||
@@ -110,6 +111,15 @@ test('on_usr1 sets ANALYZING=1 before and ANALYZING=0 after analysis', () => {
|
||||
assert.ok(analyzeReset > analyzeObsCall, 'ANALYZING=0 should follow analyze_observations');
|
||||
});
|
||||
|
||||
test('observer-loop checks pending analysis before sleeping', () => {
|
||||
const content = fs.readFileSync(observerLoopPath, 'utf8');
|
||||
assert.ok(/^PENDING_ANALYSIS=0$/m.test(content), 'PENDING_ANALYSIS should initialize to 0');
|
||||
assert.ok(
|
||||
/if \[ "\$PENDING_ANALYSIS" -eq 1 \]; then[\s\S]*?analyze_observations[\s\S]*?continue[\s\S]*?sleep "\$OBSERVER_INTERVAL_SECONDS"/.test(content),
|
||||
'observer-loop should process deferred analysis before the interval sleep'
|
||||
);
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// Test group 3: observer-loop.sh cooldown throttle
|
||||
// ──────────────────────────────────────────────────────
|
||||
@@ -334,8 +344,10 @@ test('observe.sh creates counter file and increments on each call', () => {
|
||||
// Create a minimal detect-project.sh that sets required vars
|
||||
const skillRoot = path.join(testDir, 'skill');
|
||||
const scriptsDir = path.join(skillRoot, 'scripts');
|
||||
const scriptsLibDir = path.join(scriptsDir, 'lib');
|
||||
const hooksDir = path.join(skillRoot, 'hooks');
|
||||
fs.mkdirSync(scriptsDir, { recursive: true });
|
||||
fs.mkdirSync(scriptsLibDir, { recursive: true });
|
||||
fs.mkdirSync(hooksDir, { recursive: true });
|
||||
|
||||
// Minimal detect-project.sh stub
|
||||
@@ -351,6 +363,14 @@ test('observe.sh creates counter file and increments on each call', () => {
|
||||
''
|
||||
].join('\n')
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(scriptsLibDir, 'homunculus-dir.sh'),
|
||||
[
|
||||
'#!/bin/bash',
|
||||
'_ecc_resolve_homunculus_dir() { printf "%s\\n" "$HOME/.local/share/ecc-homunculus"; }',
|
||||
''
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
// Copy observe.sh but patch SKILL_ROOT to our test dir
|
||||
let observeContent = fs.readFileSync(observeShPath, 'utf8');
|
||||
|
||||
@@ -226,6 +226,15 @@ function cleanupTestDir(testDir) {
|
||||
fs.rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function getTestHomunculusEnv(testDir) {
|
||||
const xdgDataHome = path.join(testDir, '.local', 'share');
|
||||
return {
|
||||
HOME: testDir,
|
||||
XDG_DATA_HOME: xdgDataHome,
|
||||
homunculusDir: path.join(xdgDataHome, 'ecc-homunculus'),
|
||||
};
|
||||
}
|
||||
|
||||
function writeInstinctFile(filePath, entries) {
|
||||
const body = entries.map(entry => `---
|
||||
id: ${entry.id}
|
||||
@@ -380,19 +389,20 @@ async function runTests() {
|
||||
|
||||
try {
|
||||
const sessionId = `session-${Date.now()}`;
|
||||
const homunculusEnv = getTestHomunculusEnv(testDir);
|
||||
const result = await runHookWithInput(
|
||||
path.join(scriptsDir, 'session-start.js'),
|
||||
{},
|
||||
{
|
||||
HOME: testDir,
|
||||
HOME: homunculusEnv.HOME,
|
||||
XDG_DATA_HOME: homunculusEnv.XDG_DATA_HOME,
|
||||
CLAUDE_PROJECT_DIR: projectDir,
|
||||
CLAUDE_SESSION_ID: sessionId
|
||||
}
|
||||
);
|
||||
|
||||
assert.strictEqual(result.code, 0, 'SessionStart should exit 0');
|
||||
const homunculusDir = path.join(testDir, '.claude', 'homunculus');
|
||||
const projectsDir = path.join(homunculusDir, 'projects');
|
||||
const projectsDir = path.join(homunculusEnv.homunculusDir, 'projects');
|
||||
const projectEntries = fs.existsSync(projectsDir) ? fs.readdirSync(projectsDir) : [];
|
||||
assert.ok(projectEntries.length > 0, 'SessionStart should create a homunculus project directory');
|
||||
const leaseDir = path.join(projectsDir, projectEntries[0], '.observer-sessions');
|
||||
@@ -410,7 +420,8 @@ async function runTests() {
|
||||
|
||||
try {
|
||||
const projectId = crypto.createHash('sha256').update(projectDir).digest('hex').slice(0, 12);
|
||||
const homunculusDir = path.join(testDir, '.claude', 'homunculus');
|
||||
const homunculusEnv = getTestHomunculusEnv(testDir);
|
||||
const homunculusDir = homunculusEnv.homunculusDir;
|
||||
const projectInstinctDir = path.join(homunculusDir, 'projects', projectId, 'instincts', 'personal');
|
||||
const globalInstinctDir = path.join(homunculusDir, 'instincts', 'inherited');
|
||||
|
||||
@@ -445,7 +456,8 @@ async function runTests() {
|
||||
path.join(scriptsDir, 'session-start.js'),
|
||||
{},
|
||||
{
|
||||
HOME: testDir,
|
||||
HOME: homunculusEnv.HOME,
|
||||
XDG_DATA_HOME: homunculusEnv.XDG_DATA_HOME,
|
||||
CLAUDE_PROJECT_DIR: projectDir,
|
||||
}
|
||||
);
|
||||
@@ -474,18 +486,19 @@ async function runTests() {
|
||||
});
|
||||
|
||||
try {
|
||||
const homunculusEnv = getTestHomunculusEnv(testDir);
|
||||
await runHookWithInput(
|
||||
path.join(scriptsDir, 'session-start.js'),
|
||||
{},
|
||||
{
|
||||
HOME: testDir,
|
||||
HOME: homunculusEnv.HOME,
|
||||
XDG_DATA_HOME: homunculusEnv.XDG_DATA_HOME,
|
||||
CLAUDE_PROJECT_DIR: projectDir,
|
||||
CLAUDE_SESSION_ID: sessionId
|
||||
}
|
||||
);
|
||||
|
||||
const homunculusDir = path.join(testDir, '.claude', 'homunculus');
|
||||
const projectsDir = path.join(homunculusDir, 'projects');
|
||||
const projectsDir = path.join(homunculusEnv.homunculusDir, 'projects');
|
||||
const projectEntries = fs.existsSync(projectsDir) ? fs.readdirSync(projectsDir) : [];
|
||||
assert.ok(projectEntries.length > 0, 'Expected SessionStart to create a homunculus project directory');
|
||||
const projectStorageDir = path.join(projectsDir, projectEntries[0]);
|
||||
@@ -497,7 +510,8 @@ async function runTests() {
|
||||
path.join(scriptsDir, 'session-end-marker.js'),
|
||||
markerInput,
|
||||
{
|
||||
HOME: testDir,
|
||||
HOME: homunculusEnv.HOME,
|
||||
XDG_DATA_HOME: homunculusEnv.XDG_DATA_HOME,
|
||||
CLAUDE_PROJECT_DIR: projectDir,
|
||||
CLAUDE_SESSION_ID: sessionId
|
||||
}
|
||||
|
||||
@@ -145,6 +145,23 @@ function runTests() {
|
||||
assert.match(component.description, /continuous-learning-v2/, 'Should point new installs to continuous-learning-v2');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('exposes continuous-learning-v2 as a single-skill install surface', () => {
|
||||
const component = getInstallComponent('skill:continuous-learning-v2');
|
||||
assert.strictEqual(component.id, 'skill:continuous-learning-v2');
|
||||
assert.deepStrictEqual(component.moduleIds, ['skill-continuous-learning-v2']);
|
||||
assert.ok(component.targets.includes('claude'), 'Should support Claude installs');
|
||||
|
||||
const plan = resolveInstallPlan({
|
||||
includeComponentIds: ['skill:continuous-learning-v2'],
|
||||
target: 'claude',
|
||||
});
|
||||
assert.deepStrictEqual(plan.selectedModuleIds, ['skill-continuous-learning-v2']);
|
||||
assert.ok(
|
||||
plan.operations.some(operation => operation.sourceRelativePath === 'skills/continuous-learning-v2'),
|
||||
'Should plan only the continuous-learning-v2 skill path'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('lists supported legacy compatibility languages', () => {
|
||||
const languages = listLegacyCompatibilityLanguages();
|
||||
assert.ok(languages.includes('typescript'));
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const {
|
||||
getHomunculusDir,
|
||||
normalizeRemoteUrl,
|
||||
resolveProjectContext,
|
||||
} = require('../../scripts/lib/observer-sessions');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
passed += 1;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` ${error.message}`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function createTempDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-observer-sessions-'));
|
||||
}
|
||||
|
||||
function cleanup(dir) {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
function withEnv(overrides, fn) {
|
||||
const previous = {};
|
||||
for (const key of Object.keys(overrides)) {
|
||||
previous[key] = process.env[key];
|
||||
if (overrides[key] === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = overrides[key];
|
||||
}
|
||||
}
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(previous)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function initRepo(repoDir, remoteUrl) {
|
||||
fs.mkdirSync(repoDir, { recursive: true });
|
||||
spawnSync('git', ['init'], { cwd: repoDir, stdio: 'ignore' });
|
||||
spawnSync('git', ['remote', 'add', 'origin', remoteUrl], { cwd: repoDir, stdio: 'ignore' });
|
||||
}
|
||||
|
||||
console.log('\n=== observer-sessions tests ===\n');
|
||||
|
||||
test('getHomunculusDir prefers absolute CLV2_HOMUNCULUS_DIR', () => {
|
||||
const root = createTempDir();
|
||||
try {
|
||||
const override = path.join(root, 'custom-store');
|
||||
withEnv({ CLV2_HOMUNCULUS_DIR: override, XDG_DATA_HOME: path.join(root, 'xdg') }, () => {
|
||||
assert.strictEqual(getHomunculusDir(), override);
|
||||
});
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('getHomunculusDir ignores relative overrides and uses XDG_DATA_HOME', () => {
|
||||
const root = createTempDir();
|
||||
try {
|
||||
const xdg = path.join(root, 'xdg');
|
||||
withEnv({ CLV2_HOMUNCULUS_DIR: 'relative-store', XDG_DATA_HOME: xdg }, () => {
|
||||
assert.strictEqual(getHomunculusDir(), path.join(xdg, 'ecc-homunculus'));
|
||||
});
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('normalizeRemoteUrl collapses common network remote variants', () => {
|
||||
const expected = 'github.com/owner/repo';
|
||||
assert.strictEqual(normalizeRemoteUrl('git@github.com:Owner/Repo.git'), expected);
|
||||
assert.strictEqual(normalizeRemoteUrl('https://github.com/owner/repo.git'), expected);
|
||||
assert.strictEqual(normalizeRemoteUrl('ssh://git@github.com/Owner/Repo.git'), expected);
|
||||
assert.strictEqual(normalizeRemoteUrl('https://token@github.com/owner/repo.git'), expected);
|
||||
});
|
||||
|
||||
test('normalizeRemoteUrl preserves local path case', () => {
|
||||
assert.strictEqual(normalizeRemoteUrl('/tmp/Repos/MyProject'), '/tmp/Repos/MyProject');
|
||||
assert.strictEqual(normalizeRemoteUrl('file:///tmp/Repos/MyProject.git'), '/tmp/Repos/MyProject');
|
||||
});
|
||||
|
||||
test('resolveProjectContext gives SSH and HTTPS clones the same project id', () => {
|
||||
const root = createTempDir();
|
||||
try {
|
||||
const storage = path.join(root, 'store');
|
||||
const sshRepo = path.join(root, 'ssh-clone');
|
||||
const httpsRepo = path.join(root, 'https-clone');
|
||||
initRepo(sshRepo, 'git@github.com:Owner/Repo.git');
|
||||
initRepo(httpsRepo, 'https://github.com/owner/repo.git');
|
||||
|
||||
withEnv({
|
||||
CLV2_HOMUNCULUS_DIR: storage,
|
||||
XDG_DATA_HOME: undefined,
|
||||
CLAUDE_PROJECT_DIR: undefined,
|
||||
}, () => {
|
||||
const sshContext = resolveProjectContext(sshRepo);
|
||||
const httpsContext = resolveProjectContext(httpsRepo);
|
||||
assert.strictEqual(sshContext.projectId, httpsContext.projectId);
|
||||
assert.strictEqual(sshContext.projectDir, httpsContext.projectDir);
|
||||
});
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`\nPassed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -81,6 +81,25 @@ function runTests() {
|
||||
]);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('parses --skills as skill component selections', () => {
|
||||
const parsed = parseInstallArgs([
|
||||
'node', 'install-apply.js',
|
||||
'--skills', 'continuous-learning-v2,security-review',
|
||||
]);
|
||||
assert.deepStrictEqual(parsed.includeComponentIds, [
|
||||
'skill:continuous-learning-v2',
|
||||
'skill:security-review',
|
||||
]);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('parses --skill when caller already includes the skill: prefix', () => {
|
||||
const parsed = parseInstallArgs([
|
||||
'node', 'install-apply.js',
|
||||
'--skill', 'skill:continuous-learning-v2',
|
||||
]);
|
||||
assert.deepStrictEqual(parsed.includeComponentIds, ['skill:continuous-learning-v2']);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('parses multiple --without flags', () => {
|
||||
const parsed = parseInstallArgs([
|
||||
'node', 'install-apply.js',
|
||||
@@ -244,6 +263,7 @@ function runTests() {
|
||||
const components = listInstallComponents({ family: 'skill' });
|
||||
assert.ok(components.length > 0, 'Should have at least one skill component');
|
||||
assert.ok(components.some(c => c.id === 'skill:continuous-learning'), 'Should have skill:continuous-learning');
|
||||
assert.ok(components.some(c => c.id === 'skill:continuous-learning-v2'), 'Should have skill:continuous-learning-v2');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ─── Install Plan Resolution with --with ───
|
||||
@@ -430,6 +450,22 @@ function runTests() {
|
||||
'Should include workflow-quality module from skill:continuous-learning');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('--with skill:continuous-learning-v2 installs only that skill module', () => {
|
||||
const plan = resolveInstallPlan({
|
||||
includeComponentIds: ['skill:continuous-learning-v2'],
|
||||
target: 'claude',
|
||||
});
|
||||
assert.deepStrictEqual(plan.selectedModuleIds, ['skill-continuous-learning-v2']);
|
||||
assert.ok(
|
||||
plan.operations.some(operation => operation.sourceRelativePath === 'skills/continuous-learning-v2'),
|
||||
'Should install the continuous-learning-v2 skill directory'
|
||||
);
|
||||
assert.ok(
|
||||
!plan.operations.some(operation => operation.sourceRelativePath === 'skills/tdd-workflow'),
|
||||
'Should not install the whole workflow-quality skill module'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ─── Help Text ───
|
||||
|
||||
if (test('help text documents --with and --without flags', () => {
|
||||
@@ -670,6 +706,43 @@ function runTests() {
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('end-to-end: --skills continuous-learning-v2 installs only that skill', () => {
|
||||
const { execFileSync } = require('child_process');
|
||||
const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'install-apply.js');
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'selective-skill-install-'));
|
||||
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'selective-skill-install-project-'));
|
||||
|
||||
try {
|
||||
execFileSync('node', [
|
||||
scriptPath,
|
||||
'--skills', 'continuous-learning-v2',
|
||||
], {
|
||||
cwd: projectDir,
|
||||
env: { ...process.env, HOME: homeDir },
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
const claudeRoot = path.join(homeDir, '.claude');
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(claudeRoot, 'skills', 'ecc', 'continuous-learning-v2', 'SKILL.md')),
|
||||
'Should install continuous-learning-v2'
|
||||
);
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(claudeRoot, 'skills', 'ecc', 'tdd-workflow', 'SKILL.md')),
|
||||
'Should not install unrelated workflow-quality skills'
|
||||
);
|
||||
|
||||
const statePath = path.join(claudeRoot, 'ecc', 'install-state.json');
|
||||
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
||||
assert.deepStrictEqual(state.request.includeComponents, ['skill:continuous-learning-v2']);
|
||||
assert.deepStrictEqual(state.resolution.selectedModules, ['skill-continuous-learning-v2']);
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
fs.rmSync(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ─── JSON output mode ───
|
||||
|
||||
if (test('end-to-end: --dry-run --json includes component selections in output', () => {
|
||||
|
||||
@@ -206,8 +206,8 @@ 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 does NOT have agents field (unsupported by Claude Code validator)', () => {
|
||||
@@ -226,7 +226,8 @@ test('claude plugin.json commands is an array', () => {
|
||||
});
|
||||
|
||||
test('claude plugin.json disables bundled MCP servers for provider tool-name compatibility', () => {
|
||||
const reportedOverlongToolName = `mcp__plugin_${claudePlugin.name}_github__create_pull_request_review`;
|
||||
const legacyPluginName = 'everything-claude-code';
|
||||
const reportedOverlongToolName = `mcp__plugin_${legacyPluginName}_github__create_pull_request_review`;
|
||||
|
||||
assert.ok(
|
||||
reportedOverlongToolName.length > 64,
|
||||
@@ -270,8 +271,8 @@ test('claude marketplace.json keeps only Claude-supported top-level keys', () =>
|
||||
|
||||
test('claude marketplace.json has plugins array with the published plugin entry', () => {
|
||||
assert.ok(Array.isArray(claudeMarketplace.plugins) && claudeMarketplace.plugins.length > 0, 'Expected plugins array');
|
||||
assert.strictEqual(claudeMarketplace.name, 'everything-claude-code');
|
||||
assert.strictEqual(claudeMarketplace.plugins[0].name, 'everything-claude-code');
|
||||
assert.strictEqual(claudeMarketplace.name, 'ecc');
|
||||
assert.strictEqual(claudeMarketplace.plugins[0].name, 'ecc');
|
||||
});
|
||||
|
||||
test('claude marketplace.json plugin version matches package.json', () => {
|
||||
@@ -466,18 +467,18 @@ test('README version row matches package.json', () => {
|
||||
assert.strictEqual(match[1], expectedVersion);
|
||||
});
|
||||
|
||||
test('user-facing docs do not use deprecated ecc@ecc install commands', () => {
|
||||
test('user-facing docs do not use overlong legacy marketplace install commands', () => {
|
||||
const markdownFiles = [
|
||||
path.join(repoRoot, 'README.md'),
|
||||
path.join(repoRoot, 'README.zh-CN.md'),
|
||||
path.join(repoRoot, 'skills', 'configure-ecc', 'SKILL.md'),
|
||||
...collectMarkdownFiles(path.join(repoRoot, 'docs')),
|
||||
];
|
||||
].filter(filePath => !path.relative(repoRoot, filePath).startsWith(`docs${path.sep}drafts${path.sep}`));
|
||||
|
||||
const offenders = [];
|
||||
for (const filePath of markdownFiles) {
|
||||
const source = fs.readFileSync(filePath, 'utf8');
|
||||
if (/\/plugin\s+(install|list)\s+ecc@ecc\b/.test(source)) {
|
||||
if (/\/plugin\s+(install|list)\s+everything-claude-code(?:@everything-claude-code)?\b/.test(source)) {
|
||||
offenders.push(path.relative(repoRoot, filePath));
|
||||
}
|
||||
}
|
||||
@@ -485,7 +486,7 @@ test('user-facing docs do not use deprecated ecc@ecc install commands', () => {
|
||||
assert.deepStrictEqual(
|
||||
offenders,
|
||||
[],
|
||||
`Deprecated ecc@ecc install commands must not appear in user-facing docs: ${offenders.join(', ')}`,
|
||||
`Overlong legacy install commands must not appear in user-facing docs: ${offenders.join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -79,13 +79,27 @@ argv, kwargs = module.build_terminal_launch(r'C:\\\\Users\\\\user\\\\proj & del
|
||||
print(json.dumps({'argv': argv, 'kwargs': kwargs}))
|
||||
`);
|
||||
const parsed = JSON.parse(output);
|
||||
assert.deepStrictEqual(parsed.argv.slice(0, 4), ['cmd.exe', '/k', 'cd', '/d']);
|
||||
assert.strictEqual(parsed.argv[4], parsed.kwargs.cwd);
|
||||
assert.ok(parsed.argv[4].includes('proj & del'), 'path should remain a literal argv entry');
|
||||
assert.ok(parsed.argv[4].includes('C:'), 'windows drive prefix should be preserved');
|
||||
assert.deepStrictEqual(parsed.argv, ['cmd.exe']);
|
||||
assert.ok(parsed.kwargs.cwd.includes('proj & del'), 'path should remain a literal cwd value');
|
||||
assert.ok(parsed.kwargs.cwd.includes('C:'), 'windows drive prefix should be preserved');
|
||||
assert.ok(Object.prototype.hasOwnProperty.call(parsed.kwargs, 'creationflags'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('launch_terminal rejects missing or non-directory paths', () => {
|
||||
const output = runPython(`
|
||||
import importlib.util, json
|
||||
spec = importlib.util.spec_from_file_location("ecc_dashboard_runtime", r"""${runtimeHelpersPath}""")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
try:
|
||||
module.launch_terminal('/definitely/not/a/real/ecc/path')
|
||||
except ValueError as exc:
|
||||
print(json.dumps({'error': str(exc)}))
|
||||
`);
|
||||
const parsed = JSON.parse(output);
|
||||
assert.ok(parsed.error.includes('Path is not a valid directory'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('maximize_window falls back to Linux zoom attribute when zoomed state is unsupported', () => {
|
||||
const output = runPython(`
|
||||
import importlib.util, json
|
||||
|
||||
@@ -109,6 +109,20 @@ function runTests() {
|
||||
assert.ok(parsed.operations.length > 0);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('emits JSON for --skills without pulling parent module', () => {
|
||||
const result = run([
|
||||
'--skills', 'continuous-learning-v2',
|
||||
'--target', 'claude',
|
||||
'--json',
|
||||
]);
|
||||
assert.strictEqual(result.code, 0);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.deepStrictEqual(parsed.includedComponentIds, ['skill:continuous-learning-v2']);
|
||||
assert.deepStrictEqual(parsed.selectedModuleIds, ['skill-continuous-learning-v2']);
|
||||
assert.ok(parsed.operations.some(operation => operation.sourceRelativePath === 'skills/continuous-learning-v2'));
|
||||
assert.ok(!parsed.operations.some(operation => operation.sourceRelativePath === 'skills/tdd-workflow'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('loads planning intent from ecc-install.json', () => {
|
||||
const configDir = path.join(__dirname, '..', 'fixtures', 'tmp-install-plan-config');
|
||||
const configPath = path.join(configDir, 'ecc-install.json');
|
||||
|
||||
@@ -6,10 +6,16 @@ const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'loop-status.js');
|
||||
const { analyzeTranscript, buildStatus, parseArgs } = require('../../scripts/loop-status');
|
||||
const {
|
||||
analyzeTranscript,
|
||||
buildStatus,
|
||||
getStatusExitCode,
|
||||
parseArgs,
|
||||
writeStatusSnapshots,
|
||||
} = require('../../scripts/loop-status');
|
||||
const NOW = '2026-04-30T10:00:00.000Z';
|
||||
|
||||
function run(args = [], options = {}) {
|
||||
@@ -25,25 +31,22 @@ function run(args = [], options = {}) {
|
||||
envOverrides.HOME = envOverrides.USERPROFILE;
|
||||
}
|
||||
|
||||
try {
|
||||
const stdout = execFileSync('node', [SCRIPT, ...args], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000,
|
||||
cwd: options.cwd || process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
...envOverrides,
|
||||
},
|
||||
});
|
||||
return { code: 0, stdout, stderr: '' };
|
||||
} catch (error) {
|
||||
return {
|
||||
code: error.status || 1,
|
||||
stdout: error.stdout || '',
|
||||
stderr: error.stderr || '',
|
||||
};
|
||||
}
|
||||
const result = spawnSync('node', [SCRIPT, ...args], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000,
|
||||
cwd: options.cwd || process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
...envOverrides,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
code: result.status || (result.signal ? 1 : 0),
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || '',
|
||||
};
|
||||
}
|
||||
|
||||
function createTempHome() {
|
||||
@@ -400,6 +403,7 @@ function runTests() {
|
||||
const options = parseArgs([
|
||||
'node',
|
||||
'scripts/loop-status.js',
|
||||
'--exit-code',
|
||||
'--watch',
|
||||
'--watch-count',
|
||||
'2',
|
||||
@@ -407,11 +411,74 @@ function runTests() {
|
||||
'0.01',
|
||||
]);
|
||||
|
||||
assert.strictEqual(options.exitCode, true);
|
||||
assert.strictEqual(options.watch, true);
|
||||
assert.strictEqual(options.watchCount, 2);
|
||||
assert.strictEqual(options.watchIntervalSeconds, 0.01);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('parses write-dir snapshot option', () => {
|
||||
const options = parseArgs([
|
||||
'node',
|
||||
'scripts/loop-status.js',
|
||||
'--write-dir',
|
||||
'/tmp/ecc-loop-snapshots',
|
||||
]);
|
||||
|
||||
assert.strictEqual(options.writeDir, '/tmp/ecc-loop-snapshots');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('exit-code mode returns 2 when attention signals are present', () => {
|
||||
const homeDir = createTempHome();
|
||||
|
||||
try {
|
||||
writeTranscript(homeDir, '-Users-affoon-project-exit-code', 'session-exit-code.jsonl', [
|
||||
toolUse('2026-04-30T09:10:00.000Z', 'session-exit-code', 'toolu_exit_bash', 'Bash', {
|
||||
command: 'pytest tests/integration/test_pipeline.py',
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = run(['--home', homeDir, '--now', NOW, '--json', '--exit-code']);
|
||||
|
||||
assert.strictEqual(result.code, 2, result.stderr);
|
||||
const payload = parsePayload(result.stdout);
|
||||
assert.strictEqual(payload.sessions[0].state, 'attention');
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('exit-code mode returns 1 for scan errors without attention signals', () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-loop-status-missing-'));
|
||||
const missingTranscript = path.join(tempDir, 'missing.jsonl');
|
||||
const result = run(['--transcript', missingTranscript, '--now', NOW, '--json', '--exit-code']);
|
||||
|
||||
try {
|
||||
assert.strictEqual(result.code, 1, result.stderr);
|
||||
const payload = parsePayload(result.stdout);
|
||||
assert.strictEqual(payload.sessions.length, 0);
|
||||
assert.strictEqual(payload.errors.length, 1);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('exit-code mode rejects unbounded watch mode', () => {
|
||||
const result = run(['--watch', '--exit-code']);
|
||||
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.match(result.stderr, /--exit-code with --watch requires --watch-count/);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('getStatusExitCode prioritizes attention signals over scan errors', () => {
|
||||
const payload = {
|
||||
errors: [{ message: 'unreadable' }],
|
||||
sessions: [{ state: 'attention' }],
|
||||
};
|
||||
|
||||
assert.strictEqual(getStatusExitCode(payload), 2);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('watch mode emits repeated JSON status frames', () => {
|
||||
const homeDir = createTempHome();
|
||||
|
||||
@@ -448,6 +515,233 @@ function runTests() {
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('watch mode honors exit-code after bounded refreshes', () => {
|
||||
const homeDir = createTempHome();
|
||||
|
||||
try {
|
||||
writeTranscript(homeDir, '-Users-affoon-project-watch-exit', 'session-watch-exit.jsonl', [
|
||||
toolUse('2026-04-30T09:00:00.000Z', 'session-watch-exit', 'toolu_watch_exit', 'ScheduleWakeup', {
|
||||
delaySeconds: 300,
|
||||
reason: 'Loop checkpoint',
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = run([
|
||||
'--home',
|
||||
homeDir,
|
||||
'--now',
|
||||
NOW,
|
||||
'--json',
|
||||
'--watch',
|
||||
'--watch-count',
|
||||
'1',
|
||||
'--watch-interval-seconds',
|
||||
'0.01',
|
||||
'--exit-code',
|
||||
]);
|
||||
|
||||
assert.strictEqual(result.code, 2, result.stderr);
|
||||
const frame = JSON.parse(result.stdout.trim());
|
||||
assert.strictEqual(frame.sessions[0].state, 'attention');
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('writes per-session status snapshots and index when write-dir is set', () => {
|
||||
const homeDir = createTempHome();
|
||||
const snapshotDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-loop-status-snapshots-'));
|
||||
|
||||
try {
|
||||
writeTranscript(homeDir, '-Users-affoon-project-snapshot', 'session-snapshot.jsonl', [
|
||||
toolUse('2026-04-30T09:00:00.000Z', 'session-snapshot', 'toolu_snapshot', 'ScheduleWakeup', {
|
||||
delaySeconds: 300,
|
||||
reason: 'Loop checkpoint',
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = run([
|
||||
'--home',
|
||||
homeDir,
|
||||
'--now',
|
||||
NOW,
|
||||
'--json',
|
||||
'--write-dir',
|
||||
snapshotDir,
|
||||
]);
|
||||
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
const stdoutPayload = parsePayload(result.stdout);
|
||||
assert.strictEqual(stdoutPayload.schemaVersion, 'ecc.loop-status.v1');
|
||||
|
||||
const indexPath = path.join(snapshotDir, 'index.json');
|
||||
const snapshotPath = path.join(snapshotDir, 'session-snapshot.json');
|
||||
assert.ok(fs.existsSync(indexPath), 'write-dir should include an index.json file');
|
||||
assert.ok(fs.existsSync(snapshotPath), 'write-dir should include a per-session snapshot');
|
||||
|
||||
const indexPayload = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
|
||||
assert.strictEqual(indexPayload.schemaVersion, 'ecc.loop-status.index.v1');
|
||||
assert.strictEqual(indexPayload.sessions.length, 1);
|
||||
assert.strictEqual(indexPayload.sessions[0].sessionId, 'session-snapshot');
|
||||
assert.strictEqual(indexPayload.sessions[0].state, 'attention');
|
||||
assert.strictEqual(indexPayload.sessions[0].snapshotPath, snapshotPath);
|
||||
|
||||
const snapshotPayload = JSON.parse(fs.readFileSync(snapshotPath, 'utf8'));
|
||||
assert.strictEqual(snapshotPayload.schemaVersion, 'ecc.loop-status.session.v1');
|
||||
assert.strictEqual(snapshotPayload.generatedAt, NOW);
|
||||
assert.strictEqual(snapshotPayload.session.sessionId, 'session-snapshot');
|
||||
assert.ok(snapshotPayload.session.signals.some(signal => signal.type === 'schedule_wakeup_overdue'));
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
fs.rmSync(snapshotDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('keeps index.json reserved when session id sanitizes to index', () => {
|
||||
const homeDir = createTempHome();
|
||||
const snapshotDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-loop-status-index-collision-'));
|
||||
|
||||
try {
|
||||
writeTranscript(homeDir, '-Users-affoon-project-index-collision', 'index.jsonl', [
|
||||
assistantMessage('2026-04-30T09:55:00.000Z', 'index', 'Loop checkpoint.'),
|
||||
]);
|
||||
|
||||
const result = run([
|
||||
'--home',
|
||||
homeDir,
|
||||
'--now',
|
||||
NOW,
|
||||
'--json',
|
||||
'--write-dir',
|
||||
snapshotDir,
|
||||
]);
|
||||
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
const indexPath = path.join(snapshotDir, 'index.json');
|
||||
const indexPayload = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
|
||||
assert.strictEqual(indexPayload.schemaVersion, 'ecc.loop-status.index.v1');
|
||||
assert.strictEqual(indexPayload.sessions.length, 1);
|
||||
assert.strictEqual(indexPayload.sessions[0].sessionId, 'index');
|
||||
assert.notStrictEqual(indexPayload.sessions[0].snapshotPath, indexPath);
|
||||
|
||||
const snapshotPayload = JSON.parse(fs.readFileSync(indexPayload.sessions[0].snapshotPath, 'utf8'));
|
||||
assert.strictEqual(snapshotPayload.schemaVersion, 'ecc.loop-status.session.v1');
|
||||
assert.strictEqual(snapshotPayload.session.sessionId, 'index');
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
fs.rmSync(snapshotDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('avoids Windows reserved basenames for session snapshots', () => {
|
||||
const homeDir = createTempHome();
|
||||
const snapshotDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-loop-status-windows-name-'));
|
||||
|
||||
try {
|
||||
writeTranscript(homeDir, '-Users-affoon-project-windows-name', 'con.jsonl', [
|
||||
assistantMessage('2026-04-30T09:55:00.000Z', 'con', 'Loop checkpoint.'),
|
||||
]);
|
||||
writeTranscript(homeDir, '-Users-affoon-project-windows-name', 'con-txt.jsonl', [
|
||||
assistantMessage('2026-04-30T09:56:00.000Z', 'con.txt', 'Loop checkpoint.'),
|
||||
]);
|
||||
|
||||
const result = run([
|
||||
'--home',
|
||||
homeDir,
|
||||
'--now',
|
||||
NOW,
|
||||
'--json',
|
||||
'--write-dir',
|
||||
snapshotDir,
|
||||
]);
|
||||
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
const indexPath = path.join(snapshotDir, 'index.json');
|
||||
const indexPayload = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
|
||||
assert.strictEqual(indexPayload.sessions.length, 2);
|
||||
|
||||
for (const sessionIndex of indexPayload.sessions) {
|
||||
const snapshotName = path.basename(sessionIndex.snapshotPath);
|
||||
assert.notStrictEqual(snapshotName.toLowerCase(), `${sessionIndex.sessionId}.json`);
|
||||
assert.ok(!/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(snapshotName.split('.')[0]));
|
||||
|
||||
const snapshotPayload = JSON.parse(fs.readFileSync(sessionIndex.snapshotPath, 'utf8'));
|
||||
assert.strictEqual(snapshotPayload.schemaVersion, 'ecc.loop-status.session.v1');
|
||||
assert.strictEqual(snapshotPayload.session.sessionId, sessionIndex.sessionId);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
fs.rmSync(snapshotDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('cleans temporary snapshot files when atomic rename fails', () => {
|
||||
const snapshotDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-loop-status-rename-failure-'));
|
||||
const originalRenameSync = fs.renameSync;
|
||||
|
||||
try {
|
||||
fs.renameSync = () => {
|
||||
throw new Error('simulated rename failure');
|
||||
};
|
||||
|
||||
assert.throws(() => writeStatusSnapshots({
|
||||
errors: [],
|
||||
generatedAt: NOW,
|
||||
sessions: [
|
||||
{
|
||||
eventCount: 1,
|
||||
lastEventAt: NOW,
|
||||
pendingTools: [],
|
||||
recommendedAction: 'No action needed.',
|
||||
sessionId: 'rename-failure',
|
||||
signals: [],
|
||||
state: 'ok',
|
||||
transcriptPath: path.join(snapshotDir, 'rename-failure.jsonl'),
|
||||
},
|
||||
],
|
||||
source: {},
|
||||
}, snapshotDir), /simulated rename failure/);
|
||||
|
||||
const tempFiles = fs.readdirSync(snapshotDir).filter(fileName => fileName.endsWith('.tmp'));
|
||||
assert.deepStrictEqual(tempFiles, []);
|
||||
} finally {
|
||||
fs.renameSync = originalRenameSync;
|
||||
fs.rmSync(snapshotDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('write-dir failures do not suppress normal stdout', () => {
|
||||
const homeDir = createTempHome();
|
||||
|
||||
try {
|
||||
const blockedPath = path.join(homeDir, 'snapshot-target-is-a-file');
|
||||
fs.writeFileSync(blockedPath, 'not a directory\n', 'utf8');
|
||||
writeTranscript(homeDir, '-Users-affoon-project-write-error', 'session-write-error.jsonl', [
|
||||
assistantMessage('2026-04-30T09:55:00.000Z', 'session-write-error', 'Loop checkpoint.'),
|
||||
]);
|
||||
|
||||
const result = run([
|
||||
'--home',
|
||||
homeDir,
|
||||
'--now',
|
||||
NOW,
|
||||
'--json',
|
||||
'--write-dir',
|
||||
blockedPath,
|
||||
]);
|
||||
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
const payload = parsePayload(result.stdout);
|
||||
assert.strictEqual(payload.schemaVersion, 'ecc.loop-status.v1');
|
||||
assert.strictEqual(payload.sessions[0].sessionId, 'session-write-error');
|
||||
assert.match(result.stderr, /\[loop-status\] WARNING: could not write status snapshots:/);
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from llm.core.types import LLMInput, Message, Role, ToolDefinition
|
||||
from llm.providers.claude import ClaudeProvider
|
||||
from llm.providers.openai import OpenAIProvider
|
||||
|
||||
|
||||
def _tool() -> ToolDefinition:
|
||||
return ToolDefinition(
|
||||
name="search",
|
||||
description="Search",
|
||||
parameters={"type": "object", "properties": {"query": {"type": "string"}}},
|
||||
)
|
||||
|
||||
|
||||
class _OpenAICompletions:
|
||||
def __init__(self) -> None:
|
||||
self.params = None
|
||||
|
||||
def create(self, **params):
|
||||
self.params = params
|
||||
return SimpleNamespace(
|
||||
choices=[SimpleNamespace(message=SimpleNamespace(content="ok", tool_calls=None), finish_reason="stop")],
|
||||
model=params["model"],
|
||||
usage=SimpleNamespace(prompt_tokens=1, completion_tokens=1, total_tokens=2),
|
||||
)
|
||||
|
||||
|
||||
class _OpenAIClient:
|
||||
def __init__(self) -> None:
|
||||
self.completions = _OpenAICompletions()
|
||||
self.chat = SimpleNamespace(completions=self.completions)
|
||||
|
||||
|
||||
class _AnthropicMessages:
|
||||
def __init__(self) -> None:
|
||||
self.params = None
|
||||
|
||||
def create(self, **params):
|
||||
self.params = params
|
||||
return SimpleNamespace(
|
||||
content=[SimpleNamespace(text="ok", type="text")],
|
||||
model=params["model"],
|
||||
usage=SimpleNamespace(input_tokens=1, output_tokens=1),
|
||||
stop_reason="end_turn",
|
||||
)
|
||||
|
||||
|
||||
class _AnthropicClient:
|
||||
def __init__(self) -> None:
|
||||
self.messages = _AnthropicMessages()
|
||||
self.api_key = "test"
|
||||
|
||||
|
||||
def test_openai_provider_serializes_tools_for_chat_completions():
|
||||
provider = OpenAIProvider(api_key="test")
|
||||
client = _OpenAIClient()
|
||||
provider.client = client
|
||||
|
||||
provider.generate(LLMInput(messages=[Message(role=Role.USER, content="hi")], tools=[_tool()]))
|
||||
|
||||
assert client.completions.params["tools"] == [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search",
|
||||
"description": "Search",
|
||||
"parameters": {"type": "object", "properties": {"query": {"type": "string"}}},
|
||||
"strict": True,
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_claude_provider_serializes_tools_for_messages_api():
|
||||
provider = ClaudeProvider(api_key="test")
|
||||
client = _AnthropicClient()
|
||||
provider.client = client
|
||||
|
||||
provider.generate(LLMInput(messages=[Message(role=Role.USER, content="hi")], tools=[_tool()]))
|
||||
|
||||
assert client.messages.params["tools"] == [
|
||||
{
|
||||
"name": "search",
|
||||
"description": "Search",
|
||||
"input_schema": {"type": "object", "properties": {"query": {"type": "string"}}},
|
||||
}
|
||||
]
|
||||
@@ -26,3 +26,37 @@ class TestGetProvider:
|
||||
def test_invalid_provider_raises(self):
|
||||
with pytest.raises(ValueError, match="Unknown provider type"):
|
||||
get_provider("invalid")
|
||||
|
||||
def test_saved_llm_env_selects_provider(self, monkeypatch, tmp_path):
|
||||
monkeypatch.delenv("LLM_PROVIDER", raising=False)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
tmp_path.joinpath(".llm.env").write_text("LLM_PROVIDER=ollama\nLLM_MODEL=llama3.2\n")
|
||||
|
||||
provider = get_provider()
|
||||
|
||||
assert isinstance(provider, OllamaProvider)
|
||||
|
||||
def test_env_provider_overrides_saved_llm_env(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("LLM_PROVIDER", "ollama")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
tmp_path.joinpath(".llm.env").write_text("LLM_PROVIDER=openai\n")
|
||||
|
||||
provider = get_provider()
|
||||
|
||||
assert isinstance(provider, OllamaProvider)
|
||||
|
||||
def test_env_provider_is_normalized(self, monkeypatch):
|
||||
monkeypatch.setenv("LLM_PROVIDER", "OLLAMA")
|
||||
|
||||
provider = get_provider()
|
||||
|
||||
assert isinstance(provider, OllamaProvider)
|
||||
|
||||
def test_explicit_provider_overrides_saved_llm_env(self, monkeypatch, tmp_path):
|
||||
monkeypatch.delenv("LLM_PROVIDER", raising=False)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
tmp_path.joinpath(".llm.env").write_text("LLM_PROVIDER=openai\n")
|
||||
|
||||
provider = get_provider("ollama")
|
||||
|
||||
assert isinstance(provider, OllamaProvider)
|
||||
|
||||
@@ -63,6 +63,37 @@ class TestToolDefinition:
|
||||
assert result["name"] == "search"
|
||||
assert result["strict"] is True
|
||||
|
||||
def test_tool_to_openai_tool(self):
|
||||
tool = ToolDefinition(
|
||||
name="search",
|
||||
description="Search",
|
||||
parameters={"type": "object"},
|
||||
strict=False,
|
||||
)
|
||||
|
||||
assert tool.to_openai_tool() == {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search",
|
||||
"description": "Search",
|
||||
"parameters": {"type": "object"},
|
||||
"strict": False,
|
||||
},
|
||||
}
|
||||
|
||||
def test_tool_to_anthropic_tool(self):
|
||||
tool = ToolDefinition(
|
||||
name="search",
|
||||
description="Search",
|
||||
parameters={"type": "object"},
|
||||
)
|
||||
|
||||
assert tool.to_anthropic_tool() == {
|
||||
"name": "search",
|
||||
"description": "Search",
|
||||
"input_schema": {"type": "object"},
|
||||
}
|
||||
|
||||
|
||||
class TestToolCall:
|
||||
def test_create_tool_call(self):
|
||||
|
||||
@@ -138,6 +138,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@isaacs/fs-minipass@npm:^4.0.0":
|
||||
version: 4.0.1
|
||||
resolution: "@isaacs/fs-minipass@npm:4.0.1"
|
||||
dependencies:
|
||||
minipass: "npm:^7.0.4"
|
||||
checksum: 10c0/c25b6dc1598790d5b55c0947a9b7d111cfa92594db5296c3b907e2f533c033666f692a3939eadac17b1c7c40d362d0b0635dc874cbfe3e70db7c2b07cc97a5d2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@istanbuljs/schema@npm:^0.1.2, @istanbuljs/schema@npm:^0.1.3":
|
||||
version: 0.1.3
|
||||
resolution: "@istanbuljs/schema@npm:0.1.3"
|
||||
@@ -169,30 +178,80 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64@npm:3.0.3":
|
||||
version: 3.0.3
|
||||
resolution: "@msgpackr-extract/msgpackr-extract-darwin-arm64@npm:3.0.3"
|
||||
conditions: os=darwin & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-x64@npm:3.0.3":
|
||||
version: 3.0.3
|
||||
resolution: "@msgpackr-extract/msgpackr-extract-darwin-x64@npm:3.0.3"
|
||||
conditions: os=darwin & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm64@npm:3.0.3":
|
||||
version: 3.0.3
|
||||
resolution: "@msgpackr-extract/msgpackr-extract-linux-arm64@npm:3.0.3"
|
||||
conditions: os=linux & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm@npm:3.0.3":
|
||||
version: 3.0.3
|
||||
resolution: "@msgpackr-extract/msgpackr-extract-linux-arm@npm:3.0.3"
|
||||
conditions: os=linux & cpu=arm
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-linux-x64@npm:3.0.3":
|
||||
version: 3.0.3
|
||||
resolution: "@msgpackr-extract/msgpackr-extract-linux-x64@npm:3.0.3"
|
||||
conditions: os=linux & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64@npm:3.0.3":
|
||||
version: 3.0.3
|
||||
resolution: "@msgpackr-extract/msgpackr-extract-win32-x64@npm:3.0.3"
|
||||
conditions: os=win32 & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opencode-ai/plugin@npm:^1.0.0":
|
||||
version: 1.3.15
|
||||
resolution: "@opencode-ai/plugin@npm:1.3.15"
|
||||
version: 1.14.33
|
||||
resolution: "@opencode-ai/plugin@npm:1.14.33"
|
||||
dependencies:
|
||||
"@opencode-ai/sdk": "npm:1.3.15"
|
||||
"@opencode-ai/sdk": "npm:1.14.33"
|
||||
effect: "npm:4.0.0-beta.57"
|
||||
zod: "npm:4.1.8"
|
||||
peerDependencies:
|
||||
"@opentui/core": ">=0.1.96"
|
||||
"@opentui/solid": ">=0.1.96"
|
||||
"@opentui/core": ">=0.2.2"
|
||||
"@opentui/solid": ">=0.2.2"
|
||||
peerDependenciesMeta:
|
||||
"@opentui/core":
|
||||
optional: true
|
||||
"@opentui/solid":
|
||||
optional: true
|
||||
checksum: 10c0/1a662ff700812223310612f3c8c7fd4465eda5763d726ec4d29d0eae26babf344ef176c9b987d79fe1e29c8a498178881a47d7080bb9f4db3e70dad59eb8cd9e
|
||||
checksum: 10c0/0ce3e9876e12e4d9afc664c1a03bc3bebb12147bdea9b640a1bc3ed3b871b284a75b294f72e8afc86af8140f0d89cb223367c12ee7af4d6c25e9c1373893d13a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opencode-ai/sdk@npm:1.3.15":
|
||||
version: 1.3.15
|
||||
resolution: "@opencode-ai/sdk@npm:1.3.15"
|
||||
"@opencode-ai/sdk@npm:1.14.33":
|
||||
version: 1.14.33
|
||||
resolution: "@opencode-ai/sdk@npm:1.14.33"
|
||||
dependencies:
|
||||
cross-spawn: "npm:7.0.6"
|
||||
checksum: 10c0/3957ae62e0ec1e339d9493e03a2440c95afdd64a608a2dc9db8383338650318a294280b2142305db5b0147badacbefa0d07e949d31167e5a4a49c9d057d016fa
|
||||
checksum: 10c0/10a52b224428fb05be055dbf2b96adbf1198a489a9eeedc68848231720559ec2cb98edf44b25afb2945a7609c485eea4265ea089d16fcdb4537113b78318ea21
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@standard-schema/spec@npm:^1.1.0":
|
||||
version: 1.1.0
|
||||
resolution: "@standard-schema/spec@npm:1.1.0"
|
||||
checksum: 10c0/d90f55acde4b2deb983529c87e8025fa693de1a5e8b49ecc6eb84d1fd96328add0e03d7d551442156c7432fd78165b2c26ff561b970a9a881f046abb78d6a526
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -256,6 +315,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"abbrev@npm:^4.0.0":
|
||||
version: 4.0.0
|
||||
resolution: "abbrev@npm:4.0.0"
|
||||
checksum: 10c0/b4cc16935235e80702fc90192e349e32f8ef0ed151ef506aa78c81a7c455ec18375c4125414b99f84b2e055199d66383e787675f0bcd87da7a4dbd59f9eac1d5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"acorn-jsx@npm:^5.3.2":
|
||||
version: 5.3.2
|
||||
resolution: "acorn-jsx@npm:5.3.2"
|
||||
@@ -275,26 +341,26 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"ajv@npm:^6.12.4":
|
||||
version: 6.14.0
|
||||
resolution: "ajv@npm:6.14.0"
|
||||
version: 6.15.0
|
||||
resolution: "ajv@npm:6.15.0"
|
||||
dependencies:
|
||||
fast-deep-equal: "npm:^3.1.1"
|
||||
fast-json-stable-stringify: "npm:^2.0.0"
|
||||
json-schema-traverse: "npm:^0.4.1"
|
||||
uri-js: "npm:^4.2.2"
|
||||
checksum: 10c0/a2bc39b0555dc9802c899f86990eb8eed6e366cddbf65be43d5aa7e4f3c4e1a199d5460fd7ca4fb3d864000dbbc049253b72faa83b3b30e641ca52cb29a68c22
|
||||
checksum: 10c0/67966499dd272ecde1c2e467084411132891523d057487587879d39ac04207f4351b7b2324c83198013967fbfa632c1612adc960114a30770fbe07a0773b32c2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ajv@npm:^8.18.0":
|
||||
version: 8.18.0
|
||||
resolution: "ajv@npm:8.18.0"
|
||||
version: 8.20.0
|
||||
resolution: "ajv@npm:8.20.0"
|
||||
dependencies:
|
||||
fast-deep-equal: "npm:^3.1.3"
|
||||
fast-uri: "npm:^3.0.1"
|
||||
json-schema-traverse: "npm:^1.0.0"
|
||||
require-from-string: "npm:^2.0.2"
|
||||
checksum: 10c0/e7517c426173513a07391be951879932bdf3348feaebd2199f5b901c20f99d60db8cd1591502d4d551dc82f594e82a05c4fe1c70139b15b8937f7afeaed9532f
|
||||
checksum: 10c0/5df9a1c8f83863cde1bd3a9ddb426f599718f88e3dc9153616c79fb28e0be455335830d7f21d745576519f057b371352daa31047b6a33d7036fe08777d60cf2a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -425,6 +491,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"chownr@npm:^3.0.0":
|
||||
version: 3.0.0
|
||||
resolution: "chownr@npm:3.0.0"
|
||||
checksum: 10c0/43925b87700f7e3893296c8e9c56cc58f926411cce3a6e5898136daaf08f08b9a8eb76d37d3267e707d0dcc17aed2e2ebdf5848c0c3ce95cf910a919935c1b10
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"cliui@npm:^8.0.1":
|
||||
version: 8.0.1
|
||||
resolution: "cliui@npm:8.0.1"
|
||||
@@ -533,6 +606,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"detect-libc@npm:^2.0.1":
|
||||
version: 2.1.2
|
||||
resolution: "detect-libc@npm:2.1.2"
|
||||
checksum: 10c0/acc675c29a5649fa1fb6e255f993b8ee829e510b6b56b0910666949c80c364738833417d0edb5f90e4e46be17228b0f2b66a010513984e18b15deeeac49369c4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"devlop@npm:^1.0.0":
|
||||
version: 1.1.0
|
||||
resolution: "devlop@npm:1.1.0"
|
||||
@@ -563,6 +643,24 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"effect@npm:4.0.0-beta.57":
|
||||
version: 4.0.0-beta.57
|
||||
resolution: "effect@npm:4.0.0-beta.57"
|
||||
dependencies:
|
||||
"@standard-schema/spec": "npm:^1.1.0"
|
||||
fast-check: "npm:^4.6.0"
|
||||
find-my-way-ts: "npm:^0.1.6"
|
||||
ini: "npm:^6.0.0"
|
||||
kubernetes-types: "npm:^1.30.0"
|
||||
msgpackr: "npm:^1.11.9"
|
||||
multipasta: "npm:^0.2.7"
|
||||
toml: "npm:^4.1.1"
|
||||
uuid: "npm:^13.0.0"
|
||||
yaml: "npm:^2.8.3"
|
||||
checksum: 10c0/0ae765176b305f6ec9c067122cdd0adae8c83b233973df57200b3fb68e417f94cd7e539e71fff520f9c98be59404a23d68989cd43a4b53d9926e9ae91ee13a44
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"emoji-regex@npm:^8.0.0":
|
||||
version: 8.0.0
|
||||
resolution: "emoji-regex@npm:8.0.0"
|
||||
@@ -577,6 +675,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"env-paths@npm:^2.2.0":
|
||||
version: 2.2.1
|
||||
resolution: "env-paths@npm:2.2.1"
|
||||
checksum: 10c0/285325677bf00e30845e330eec32894f5105529db97496ee3f598478e50f008c5352a41a30e5e72ec9de8a542b5a570b85699cd63bd2bc646dbcb9f311d83bc4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"escalade@npm:^3.1.1":
|
||||
version: 3.2.0
|
||||
resolution: "escalade@npm:3.2.0"
|
||||
@@ -707,6 +812,22 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"exponential-backoff@npm:^3.1.1":
|
||||
version: 3.1.3
|
||||
resolution: "exponential-backoff@npm:3.1.3"
|
||||
checksum: 10c0/77e3ae682b7b1f4972f563c6dbcd2b0d54ac679e62d5d32f3e5085feba20483cf28bd505543f520e287a56d4d55a28d7874299941faf637e779a1aa5994d1267
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fast-check@npm:^4.6.0":
|
||||
version: 4.7.0
|
||||
resolution: "fast-check@npm:4.7.0"
|
||||
dependencies:
|
||||
pure-rand: "npm:^8.0.0"
|
||||
checksum: 10c0/7edce2b82d11d5325e9e79a2377e1f6e7200d27219edda2e3449d827e994c34461132fc149c90e41b78fc8e6ef4aae77d45350ac7bb1bc4a81110401d0a49fbc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3":
|
||||
version: 3.1.3
|
||||
resolution: "fast-deep-equal@npm:3.1.3"
|
||||
@@ -729,9 +850,9 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"fast-uri@npm:^3.0.1":
|
||||
version: 3.1.0
|
||||
resolution: "fast-uri@npm:3.1.0"
|
||||
checksum: 10c0/44364adca566f70f40d1e9b772c923138d47efeac2ae9732a872baafd77061f26b097ba2f68f0892885ad177becd065520412b8ffeec34b16c99433c5b9e2de7
|
||||
version: 3.1.2
|
||||
resolution: "fast-uri@npm:3.1.2"
|
||||
checksum: 10c0/5b35641895959f3f7ab7a7b1b5542bded159346f25ec9f256817b206d50b64eda5828e90d605a2e2fc645c90519a7259c2bab2c942ee728c88b88e5be21b090d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -756,6 +877,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"find-my-way-ts@npm:^0.1.6":
|
||||
version: 0.1.6
|
||||
resolution: "find-my-way-ts@npm:0.1.6"
|
||||
checksum: 10c0/16ad4b15275b56ee0ec361d0c61afbdff4c75bd0ac04112f6910f188cb1058096ba63529c2363914da6bb60266aa4def1025af04af26368ff87eb0df52f2862f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"find-up@npm:^5.0.0":
|
||||
version: 5.0.0
|
||||
resolution: "find-up@npm:5.0.0"
|
||||
@@ -835,9 +963,16 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"globals@npm:^17.4.0":
|
||||
version: 17.4.0
|
||||
resolution: "globals@npm:17.4.0"
|
||||
checksum: 10c0/2be9e8c2b9035836f13d420b22f0247a328db82967d3bebfc01126d888ed609305f06c05895914e969653af5c6ba35fd7a0920f3e6c869afa60666c810630feb
|
||||
version: 17.6.0
|
||||
resolution: "globals@npm:17.6.0"
|
||||
checksum: 10c0/cf94fb4329cc5c68cf81018fd68324f413181ee169f0235b0b33b82bc93fe7825a21beea951f83a80e8e4bbdad9c0c80515a145b5fd4b5cb52f2a80db899a93f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"graceful-fs@npm:^4.2.6":
|
||||
version: 4.2.11
|
||||
resolution: "graceful-fs@npm:4.2.11"
|
||||
checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -886,6 +1021,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ini@npm:^6.0.0":
|
||||
version: 6.0.0
|
||||
resolution: "ini@npm:6.0.0"
|
||||
checksum: 10c0/9a7f55f306e2b25b41ae67c8b526e8f4673f057b70852b9025816ef4f15f07bf1ba35ed68ea4471ff7b31718f7ef1bc50d709f8d03cb012e10a3135eb99c7206
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ini@npm:~4.1.0":
|
||||
version: 4.1.3
|
||||
resolution: "ini@npm:4.1.3"
|
||||
@@ -954,6 +1096,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"isexe@npm:^4.0.0":
|
||||
version: 4.0.0
|
||||
resolution: "isexe@npm:4.0.0"
|
||||
checksum: 10c0/5884815115bceac452877659a9c7726382531592f43dc29e5d48b7c4100661aed54018cb90bd36cb2eaeba521092570769167acbb95c18d39afdccbcca06c5ce
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.0":
|
||||
version: 3.2.2
|
||||
resolution: "istanbul-lib-coverage@npm:3.2.2"
|
||||
@@ -1055,6 +1204,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"kubernetes-types@npm:^1.30.0":
|
||||
version: 1.30.0
|
||||
resolution: "kubernetes-types@npm:1.30.0"
|
||||
checksum: 10c0/de3641e4f50cfc123c4102a73c12932e1db8e51783c7cae4ea8ad3561bd56fab0f1c2346801f84a4c36aae8cea0b25d21e9514cc0fcecd4d64b1314043263076
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"levn@npm:^0.4.1":
|
||||
version: 0.4.1
|
||||
resolution: "levn@npm:0.4.1"
|
||||
@@ -1488,13 +1644,22 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"minipass@npm:^7.1.2, minipass@npm:^7.1.3":
|
||||
"minipass@npm:^7.0.4, minipass@npm:^7.1.2, minipass@npm:^7.1.3":
|
||||
version: 7.1.3
|
||||
resolution: "minipass@npm:7.1.3"
|
||||
checksum: 10c0/539da88daca16533211ea5a9ee98dc62ff5742f531f54640dd34429e621955e91cc280a91a776026264b7f9f6735947629f920944e9c1558369e8bf22eb33fbb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"minizlib@npm:^3.1.0":
|
||||
version: 3.1.0
|
||||
resolution: "minizlib@npm:3.1.0"
|
||||
dependencies:
|
||||
minipass: "npm:^7.1.2"
|
||||
checksum: 10c0/5aad75ab0090b8266069c9aabe582c021ae53eb33c6c691054a13a45db3b4f91a7fb1bd79151e6b4e9e9a86727b522527c0a06ec7d45206b745d54cd3097bcec
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ms@npm:^2.1.3":
|
||||
version: 2.1.3
|
||||
resolution: "ms@npm:2.1.3"
|
||||
@@ -1502,6 +1667,56 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"msgpackr-extract@npm:^3.0.2":
|
||||
version: 3.0.3
|
||||
resolution: "msgpackr-extract@npm:3.0.3"
|
||||
dependencies:
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "npm:3.0.3"
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-x64": "npm:3.0.3"
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm": "npm:3.0.3"
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm64": "npm:3.0.3"
|
||||
"@msgpackr-extract/msgpackr-extract-linux-x64": "npm:3.0.3"
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64": "npm:3.0.3"
|
||||
node-gyp: "npm:latest"
|
||||
node-gyp-build-optional-packages: "npm:5.2.2"
|
||||
dependenciesMeta:
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64":
|
||||
optional: true
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-x64":
|
||||
optional: true
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm":
|
||||
optional: true
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm64":
|
||||
optional: true
|
||||
"@msgpackr-extract/msgpackr-extract-linux-x64":
|
||||
optional: true
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64":
|
||||
optional: true
|
||||
bin:
|
||||
download-msgpackr-prebuilds: bin/download-prebuilds.js
|
||||
checksum: 10c0/e504fd8bf86a29d7527c83776530ee6dc92dcb0273bb3679fd4a85173efead7f0ee32fb82c8410a13c33ef32828c45f81118ffc0fbed5d6842e72299894623b4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"msgpackr@npm:^1.11.9":
|
||||
version: 1.11.12
|
||||
resolution: "msgpackr@npm:1.11.12"
|
||||
dependencies:
|
||||
msgpackr-extract: "npm:^3.0.2"
|
||||
dependenciesMeta:
|
||||
msgpackr-extract:
|
||||
optional: true
|
||||
checksum: 10c0/e9f1460e363dbd8c81a5c1b5829980edea7d76e91d570d094d0a4dae0d8ad12f64dea11b2be15f3d7b48d615fa9d3c9b600a6894fd272526087fa33753b5fd16
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"multipasta@npm:^0.2.7":
|
||||
version: 0.2.7
|
||||
resolution: "multipasta@npm:0.2.7"
|
||||
checksum: 10c0/15917ac88aeefa5b8afac44b90d1e9d0d0ec7148b51e0766f07a69a220ecebcb6404539a856c45aa85a3d7fe517bc58febe81437146705f17ecd2961dc0b9fa5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"natural-compare@npm:^1.4.0":
|
||||
version: 1.4.0
|
||||
resolution: "natural-compare@npm:1.4.0"
|
||||
@@ -1509,6 +1724,50 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"node-gyp-build-optional-packages@npm:5.2.2":
|
||||
version: 5.2.2
|
||||
resolution: "node-gyp-build-optional-packages@npm:5.2.2"
|
||||
dependencies:
|
||||
detect-libc: "npm:^2.0.1"
|
||||
bin:
|
||||
node-gyp-build-optional-packages: bin.js
|
||||
node-gyp-build-optional-packages-optional: optional.js
|
||||
node-gyp-build-optional-packages-test: build-test.js
|
||||
checksum: 10c0/c81128c6f91873381be178c5eddcbdf66a148a6a89a427ce2bcd457593ce69baf2a8662b6d22cac092d24aa9c43c230dec4e69b3a0da604503f4777cd77e282b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"node-gyp@npm:latest":
|
||||
version: 12.3.0
|
||||
resolution: "node-gyp@npm:12.3.0"
|
||||
dependencies:
|
||||
env-paths: "npm:^2.2.0"
|
||||
exponential-backoff: "npm:^3.1.1"
|
||||
graceful-fs: "npm:^4.2.6"
|
||||
nopt: "npm:^9.0.0"
|
||||
proc-log: "npm:^6.0.0"
|
||||
semver: "npm:^7.3.5"
|
||||
tar: "npm:^7.5.4"
|
||||
tinyglobby: "npm:^0.2.12"
|
||||
undici: "npm:^6.25.0"
|
||||
which: "npm:^6.0.0"
|
||||
bin:
|
||||
node-gyp: bin/node-gyp.js
|
||||
checksum: 10c0/9d9032b405cbe42f72a105259d9eb679376470c102df4a2dbaa51e07d59bf741dcffb85897087ea9d8318b9cabb824a8978af51508ae142f0239ae1e6a3c2329
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"nopt@npm:^9.0.0":
|
||||
version: 9.0.0
|
||||
resolution: "nopt@npm:9.0.0"
|
||||
dependencies:
|
||||
abbrev: "npm:^4.0.0"
|
||||
bin:
|
||||
nopt: bin/nopt.js
|
||||
checksum: 10c0/1822eb6f9b020ef6f7a7516d7b64a8036e09666ea55ac40416c36e4b2b343122c3cff0e2f085675f53de1d2db99a2a89a60ccea1d120bcd6a5347bf6ceb4a7fd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"optionator@npm:^0.9.3":
|
||||
version: 0.9.4
|
||||
resolution: "optionator@npm:0.9.4"
|
||||
@@ -1589,7 +1848,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"picomatch@npm:^4.0.3":
|
||||
"picomatch@npm:^4.0.3, picomatch@npm:^4.0.4":
|
||||
version: 4.0.4
|
||||
resolution: "picomatch@npm:4.0.4"
|
||||
checksum: 10c0/e2c6023372cc7b5764719a5ffb9da0f8e781212fa7ca4bd0562db929df8e117460f00dff3cb7509dacfc06b86de924b247f504d0ce1806a37fac4633081466b0
|
||||
@@ -1603,6 +1862,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"proc-log@npm:^6.0.0":
|
||||
version: 6.1.0
|
||||
resolution: "proc-log@npm:6.1.0"
|
||||
checksum: 10c0/4f178d4062733ead9d71a9b1ab24ebcecdfe2250916a5b1555f04fe2eda972a0ec76fbaa8df1ad9c02707add6749219d118a4fc46dc56bdfe4dde4b47d80bb82
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"punycode.js@npm:^2.3.1":
|
||||
version: 2.3.1
|
||||
resolution: "punycode.js@npm:2.3.1"
|
||||
@@ -1617,6 +1883,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"pure-rand@npm:^8.0.0":
|
||||
version: 8.4.0
|
||||
resolution: "pure-rand@npm:8.4.0"
|
||||
checksum: 10c0/6414bbc1c6f45fb774173431c7205e79783b77cfae0e2145e741b6999363554dbd2f4210d2a5bc08683e0b2f6823198c9308766b1d0911e1dccd7beb8842f860
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"require-directory@npm:^2.1.1":
|
||||
version: 2.1.1
|
||||
resolution: "require-directory@npm:2.1.1"
|
||||
@@ -1652,7 +1925,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"semver@npm:^7.5.3":
|
||||
"semver@npm:^7.3.5, semver@npm:^7.5.3":
|
||||
version: 7.7.4
|
||||
resolution: "semver@npm:7.7.4"
|
||||
bin:
|
||||
@@ -1753,6 +2026,19 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tar@npm:^7.5.4":
|
||||
version: 7.5.13
|
||||
resolution: "tar@npm:7.5.13"
|
||||
dependencies:
|
||||
"@isaacs/fs-minipass": "npm:^4.0.0"
|
||||
chownr: "npm:^3.0.0"
|
||||
minipass: "npm:^7.1.2"
|
||||
minizlib: "npm:^3.1.0"
|
||||
yallist: "npm:^5.0.0"
|
||||
checksum: 10c0/5c65b8084799bde7a791593a1c1a45d3d6ee98182e3700b24c247b7b8f8654df4191642abbdb07ff25043d45dcff35620827c3997b88ae6c12040f64bed5076b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"test-exclude@npm:^8.0.0":
|
||||
version: 8.0.0
|
||||
resolution: "test-exclude@npm:8.0.0"
|
||||
@@ -1764,6 +2050,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tinyglobby@npm:^0.2.12":
|
||||
version: 0.2.16
|
||||
resolution: "tinyglobby@npm:0.2.16"
|
||||
dependencies:
|
||||
fdir: "npm:^6.5.0"
|
||||
picomatch: "npm:^4.0.4"
|
||||
checksum: 10c0/f2e09fd93dd95c41e522113b686ff6f7c13020962f8698a864a257f3d7737599afc47722b7ab726e12f8a813f779906187911ff8ee6701ede65072671a7e934b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tinyglobby@npm:~0.2.15":
|
||||
version: 0.2.15
|
||||
resolution: "tinyglobby@npm:0.2.15"
|
||||
@@ -1774,6 +2070,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"toml@npm:^4.1.1":
|
||||
version: 4.1.1
|
||||
resolution: "toml@npm:4.1.1"
|
||||
checksum: 10c0/077bc02ac1ce82091ea073f675d7e2a1df487d1b18bbc7e653daba4956d545954b7095e979b8792f0837339b901ee190ad4464342e5e377c36bbdeca8903e079
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"type-check@npm:^0.4.0, type-check@npm:~0.4.0":
|
||||
version: 0.4.0
|
||||
resolution: "type-check@npm:0.4.0"
|
||||
@@ -1817,6 +2120,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"undici@npm:^6.25.0":
|
||||
version: 6.25.0
|
||||
resolution: "undici@npm:6.25.0"
|
||||
checksum: 10c0/2597cc6689bdb02c210c557b1f85febbfda65becae6e6fc1061508e2f33734d25207f81cd8af56ada9956329eb3a7bd7431e87dcfeceba20ee87059b57dcf985
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"uri-js@npm:^4.2.2":
|
||||
version: 4.4.1
|
||||
resolution: "uri-js@npm:4.4.1"
|
||||
@@ -1826,6 +2136,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"uuid@npm:^13.0.0":
|
||||
version: 13.0.1
|
||||
resolution: "uuid@npm:13.0.1"
|
||||
bin:
|
||||
uuid: dist-node/bin/uuid
|
||||
checksum: 10c0/7bb8ad18b11871b7bd1b9161a60610c2b6ce8f7300d93932f92117a2ab9b40479dd23e81929ac848e8a7c45f78b8ed3333f88694b71c17ff3265e443f8684642
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"v8-to-istanbul@npm:^9.0.0":
|
||||
version: 9.3.0
|
||||
resolution: "v8-to-istanbul@npm:9.3.0"
|
||||
@@ -1848,6 +2167,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"which@npm:^6.0.0":
|
||||
version: 6.0.1
|
||||
resolution: "which@npm:6.0.1"
|
||||
dependencies:
|
||||
isexe: "npm:^4.0.0"
|
||||
bin:
|
||||
node-which: bin/which.js
|
||||
checksum: 10c0/7e710e54ea36d2d6183bee2f9caa27a3b47b9baf8dee55a199b736fcf85eab3b9df7556fca3d02b50af7f3dfba5ea3a45644189836df06267df457e354da66d5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"word-wrap@npm:^1.2.5":
|
||||
version: 1.2.5
|
||||
resolution: "word-wrap@npm:1.2.5"
|
||||
@@ -1873,6 +2203,22 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"yallist@npm:^5.0.0":
|
||||
version: 5.0.0
|
||||
resolution: "yallist@npm:5.0.0"
|
||||
checksum: 10c0/a499c81ce6d4a1d260d4ea0f6d49ab4da09681e32c3f0472dee16667ed69d01dae63a3b81745a24bd78476ec4fcf856114cb4896ace738e01da34b2c42235416
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"yaml@npm:^2.8.3":
|
||||
version: 2.8.4
|
||||
resolution: "yaml@npm:2.8.4"
|
||||
bin:
|
||||
yaml: bin.mjs
|
||||
checksum: 10c0/0a33a1fa28d4bc79f61a12ec7ef7a2bce0ce5f8e80c6eaecfb4a0c88c08767dd1ede372b6a3bcd70891213b8c9f3169b355c97e77026d3b3459e10d2cccaef1e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"yargs-parser@npm:^21.1.1":
|
||||
version: 21.1.1
|
||||
resolution: "yargs-parser@npm:21.1.1"
|
||||
|
||||
Reference in New Issue
Block a user