mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-14 12:11:27 +08:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c06ad9524 | |||
| b39d2244cf | |||
| d8f879e671 | |||
| d352270b9a | |||
| 6fd20ffc72 | |||
| 7fa1e5b6db | |||
| f442bac8c9 | |||
| 12e1bc424d | |||
| e674a7dbd7 | |||
| 1abc3fb381 | |||
| 27508842b1 | |||
| 8a57679222 | |||
| 7b964402ee | |||
| f8a0c4f884 | |||
| 754bdbf440 | |||
| f01929c31a | |||
| e196f8a4cb | |||
| 600072ebd8 | |||
| 2bb88cff47 | |||
| 105b524c8f | |||
| 61a30a1f15 | |||
| c013479019 | |||
| baba4ec1ab | |||
| 01b171947c |
@@ -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, 188 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, 188 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"
|
||||
@@ -23,6 +23,10 @@
|
||||
"best-practices"
|
||||
],
|
||||
"mcpServers": {},
|
||||
"skills": ["./skills/"],
|
||||
"commands": ["./commands/"]
|
||||
"skills": [
|
||||
"./skills/"
|
||||
],
|
||||
"commands": [
|
||||
"./commands/"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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, 188 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/ — 188 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, 188 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: 188 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** | 188 | 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 个代理、188 个技能和 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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -3,7 +3,6 @@ name: a11y-architect
|
||||
description: Accessibility Architect specializing in WCAG 2.2 compliance for Web and Native platforms. Use PROACTIVELY when designing UI components, establishing design systems, or auditing code for inclusive user experiences.
|
||||
model: sonnet
|
||||
tools: ["Read", "Write", "Edit", "Bash", "Grep", "Glob"]
|
||||
model: opus
|
||||
---
|
||||
|
||||
You are a Senior Accessibility Architect. Your goal is to ensure that every digital product is Perceivable, Operable, Understandable, and Robust (POUR) for all users, including those with visual, auditory, motor, or cognitive disabilities.
|
||||
|
||||
@@ -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?"
|
||||
@@ -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 个专业代理、188 项技能、68 条命令以及自动化钩子工作流,用于软件开发。
|
||||
|
||||
**版本:** 2.0.0-rc.1
|
||||
|
||||
@@ -146,8 +146,8 @@
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
agents/ — 48 个专业子代理
|
||||
skills/ — 182 个工作流技能和领域知识
|
||||
agents/ — 50 个专业子代理
|
||||
skills/ — 188 个工作流技能和领域知识
|
||||
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 个智能体、188 项技能和 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: 188 项 | 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 |
|
||||
| **技能** | 188 | 共享 | 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"""
|
||||
|
||||
@@ -149,7 +149,8 @@
|
||||
"skills/rust-testing",
|
||||
"skills/springboot-patterns",
|
||||
"skills/springboot-tdd",
|
||||
"skills/springboot-verification"
|
||||
"skills/springboot-verification",
|
||||
"skills/ui-to-vue"
|
||||
],
|
||||
"targets": [
|
||||
"claude",
|
||||
|
||||
@@ -227,6 +227,7 @@
|
||||
"skills/terminal-ops/",
|
||||
"skills/token-budget-advisor/",
|
||||
"skills/ui-demo/",
|
||||
"skills/ui-to-vue/",
|
||||
"skills/unified-notifications-ops/",
|
||||
"skills/verification-loop/",
|
||||
"skills/video-editing/",
|
||||
|
||||
+11
-2
@@ -44,20 +44,29 @@ Equivalent local commands via `yarn prettier` or `npm exec prettier --` are fine
|
||||
|
||||
### Type Check
|
||||
|
||||
Use `--incremental` so re-runs reuse the previous `.tsbuildinfo` (1-3s on unchanged code instead of 30-60s every time). Wrap in `timeout` so a stuck tsc gets reaped by the OS instead of accumulating across edits — this prevents the multi-process buildup that happens when edits fire faster than tsc finishes.
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit",
|
||||
"command": "pnpm tsc --noEmit --pretty false",
|
||||
"description": "Type-check after frontend edits"
|
||||
"command": "timeout 60 pnpm tsc --noEmit --pretty false --incremental --tsBuildInfoFile node_modules/.cache/tsc-hook.tsbuildinfo",
|
||||
"description": "Type-check after frontend edits (incremental + timeout-capped)"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why both flags matter:**
|
||||
- Without `--incremental`, every edit re-checks the entire program from scratch. On a real Next.js project this stacks fast: edits at 5-10s intervals + 30-60s tsc runs = N concurrent tsc processes.
|
||||
- Without `timeout`, a tsc that hangs (transitive dep change, type-checker stuck on a recursive type) never exits and orphans when the parent shell does.
|
||||
- `--tsBuildInfoFile` is required because `--noEmit` normally suppresses the buildinfo write; specifying the path explicitly keeps incremental working.
|
||||
|
||||
If you're on Windows without GNU coreutils, swap `timeout 60` for a PowerShell wrapper or rely on a Stop/SessionEnd hook to sweep stale tsc processes.
|
||||
|
||||
### CSS Lint
|
||||
|
||||
```json
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -18,15 +18,25 @@ function extractFrontmatter(content) {
|
||||
if (!match) return null;
|
||||
|
||||
const frontmatter = {};
|
||||
const duplicates = [];
|
||||
const lines = match[1].split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
// Only top-level keys are unique. Indented YAML belongs to nested values.
|
||||
if (/^\s/.test(line)) continue;
|
||||
const colonIdx = line.indexOf(':');
|
||||
if (colonIdx > 0) {
|
||||
const key = line.slice(0, colonIdx).trim();
|
||||
const value = line.slice(colonIdx + 1).trim();
|
||||
if (Object.prototype.hasOwnProperty.call(frontmatter, key)) {
|
||||
duplicates.push(key);
|
||||
}
|
||||
frontmatter[key] = value;
|
||||
}
|
||||
}
|
||||
Object.defineProperty(frontmatter, '__duplicates__', {
|
||||
value: duplicates,
|
||||
enumerable: false,
|
||||
});
|
||||
return frontmatter;
|
||||
}
|
||||
|
||||
@@ -57,6 +67,11 @@ function validateAgents() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (frontmatter.__duplicates__.length > 0) {
|
||||
console.error(`ERROR: ${file} - Duplicate frontmatter keys: ${[...new Set(frontmatter.__duplicates__)].join(', ')}`);
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
for (const field of REQUIRED_FIELDS) {
|
||||
if (!frontmatter[field] || (typeof frontmatter[field] === 'string' && !frontmatter[field].trim())) {
|
||||
console.error(`ERROR: ${file} - Missing required field: ${field}`);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -376,6 +376,21 @@ function withRecoveryHint(message, hookIds = [EDIT_WRITE_HOOK_ID]) {
|
||||
].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, options = {}) {
|
||||
@@ -422,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 || '';
|
||||
@@ -429,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();
|
||||
@@ -440,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 || '';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -430,51 +430,14 @@ export const DELETE = requirePermission('delete')(
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
### Simple In-Memory Rate Limiter
|
||||
Rate limiting must use a shared store such as Redis, a gateway, or the
|
||||
platform's native limiter. Do not use per-process in-memory counters for
|
||||
production APIs: they reset on deploy, split across replicas, and fail open in
|
||||
serverless or multi-instance environments.
|
||||
|
||||
```typescript
|
||||
class RateLimiter {
|
||||
private requests = new Map<string, number[]>()
|
||||
|
||||
async checkLimit(
|
||||
identifier: string,
|
||||
maxRequests: number,
|
||||
windowMs: number
|
||||
): Promise<boolean> {
|
||||
const now = Date.now()
|
||||
const requests = this.requests.get(identifier) || []
|
||||
|
||||
// Remove old requests outside window
|
||||
const recentRequests = requests.filter(time => now - time < windowMs)
|
||||
|
||||
if (recentRequests.length >= maxRequests) {
|
||||
return false // Rate limit exceeded
|
||||
}
|
||||
|
||||
// Add current request
|
||||
recentRequests.push(now)
|
||||
this.requests.set(identifier, recentRequests)
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const limiter = new RateLimiter()
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const ip = request.headers.get('x-forwarded-for') || 'unknown'
|
||||
|
||||
const allowed = await limiter.checkLimit(ip, 100, 60000) // 100 req/min
|
||||
|
||||
if (!allowed) {
|
||||
return NextResponse.json({
|
||||
error: 'Rate limit exceeded'
|
||||
}, { status: 429 })
|
||||
}
|
||||
|
||||
// Continue with request
|
||||
}
|
||||
```
|
||||
Keep the backend layer responsible for choosing the integration point and error
|
||||
shape; use `api-design` for the HTTP contract and `security-review` for abuse
|
||||
case review.
|
||||
|
||||
## Background Jobs & Queues
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -6,6 +6,10 @@ origin: ECC
|
||||
|
||||
# Deep Research
|
||||
|
||||
> **Drift-prone skill.** Firecrawl/Exa MCP tool names, quotas, and result
|
||||
> shapes change. Verify the configured MCP tools and current API docs before
|
||||
> promising coverage or quoting live source counts.
|
||||
|
||||
Produce thorough, cited research reports from multiple web sources using firecrawl and exa MCP tools.
|
||||
|
||||
## When to Activate
|
||||
|
||||
@@ -6,6 +6,10 @@ origin: ECC
|
||||
|
||||
# Exa Search
|
||||
|
||||
> **Drift-prone skill.** Exa MCP tool names, parameters, and account limits can
|
||||
> change. Confirm the exposed tool surface and current Exa docs before relying
|
||||
> on a specific search mode, category, or livecrawl behavior.
|
||||
|
||||
Neural search for web content, code, companies, and people via the Exa MCP server.
|
||||
|
||||
## When to Activate
|
||||
|
||||
@@ -6,6 +6,10 @@ origin: ECC
|
||||
|
||||
# fal.ai Media Generation
|
||||
|
||||
> **Drift-prone skill.** fal.ai model IDs, pricing, inputs, and MCP tool names
|
||||
> change quickly. Search or fetch the current model metadata before promising a
|
||||
> specific model, parameter, output format, or cost.
|
||||
|
||||
Generate images, videos, and audio using fal.ai models via MCP.
|
||||
|
||||
## When to Activate
|
||||
|
||||
@@ -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/)
|
||||
@@ -0,0 +1,122 @@
|
||||
# Animation Patterns Reference
|
||||
|
||||
Use this reference when generating presentations. Match animations to the intended feeling.
|
||||
|
||||
## Effect-to-Feeling Guide
|
||||
|
||||
| Feeling | Animations | Visual Cues |
|
||||
|---------|-----------|-------------|
|
||||
| **Dramatic / Cinematic** | Slow fade-ins (1-1.5s), large-scale transitions (0.9 to 1), parallax scrolling | Dark backgrounds, spotlight effects, full-bleed images |
|
||||
| **Techy / Futuristic** | Neon glow (box-shadow), glitch/scramble text, grid reveals | Particle systems (canvas), grid patterns, monospace accents, cyan/magenta/electric blue |
|
||||
| **Playful / Friendly** | Bouncy easing (spring physics), floating/bobbing | Rounded corners, pastel/bright colors, hand-drawn elements |
|
||||
| **Professional / Corporate** | Subtle fast animations (200-300ms), clean slides | Navy/slate/charcoal, precise spacing, data visualization focus |
|
||||
| **Calm / Minimal** | Very slow subtle motion, gentle fades | High whitespace, muted palette, serif typography, generous padding |
|
||||
| **Editorial / Magazine** | Staggered text reveals, image-text interplay | Strong type hierarchy, pull quotes, grid-breaking layouts, serif headlines + sans body |
|
||||
|
||||
## Entrance Animations
|
||||
|
||||
```css
|
||||
/* Fade + Slide Up (most versatile) */
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
transition: opacity 0.6s var(--ease-out-expo),
|
||||
transform 0.6s var(--ease-out-expo);
|
||||
}
|
||||
.visible .reveal {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Scale In */
|
||||
.reveal-scale {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
transition: opacity 0.6s, transform 0.6s var(--ease-out-expo);
|
||||
}
|
||||
.visible .reveal-scale {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
/* Slide from Left */
|
||||
.reveal-left {
|
||||
opacity: 0;
|
||||
transform: translateX(-50px);
|
||||
transition: opacity 0.6s, transform 0.6s var(--ease-out-expo);
|
||||
}
|
||||
.visible .reveal-left {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* Blur In */
|
||||
.reveal-blur {
|
||||
opacity: 0;
|
||||
filter: blur(10px);
|
||||
transition: opacity 0.8s, filter 0.8s var(--ease-out-expo);
|
||||
}
|
||||
.visible .reveal-blur {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
}
|
||||
```
|
||||
|
||||
## Background Effects
|
||||
|
||||
```css
|
||||
/* Gradient Mesh — layered radial gradients for depth */
|
||||
.gradient-bg {
|
||||
background:
|
||||
radial-gradient(ellipse at 20% 80%, rgba(120, 0, 255, 0.3) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 80% 20%, rgba(0, 255, 200, 0.2) 0%, transparent 50%),
|
||||
var(--bg-primary);
|
||||
}
|
||||
|
||||
/* Noise Texture — inline SVG for grain */
|
||||
.noise-bg {
|
||||
background-image: url("data:image/svg+xml,..."); /* Inline SVG noise */
|
||||
}
|
||||
|
||||
/* Grid Pattern — subtle structural lines */
|
||||
.grid-bg {
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px);
|
||||
background-size: 50px 50px;
|
||||
}
|
||||
```
|
||||
|
||||
## Interactive Effects
|
||||
|
||||
```javascript
|
||||
/* 3D Tilt on Hover — adds depth to cards/panels */
|
||||
class TiltEffect {
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
this.element.style.transformStyle = 'preserve-3d';
|
||||
this.element.style.perspective = '1000px';
|
||||
|
||||
this.element.addEventListener('mousemove', (e) => {
|
||||
const rect = this.element.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / rect.width - 0.5;
|
||||
const y = (e.clientY - rect.top) / rect.height - 0.5;
|
||||
this.element.style.transform = `rotateY(${x * 10}deg) rotateX(${-y * 10}deg)`;
|
||||
});
|
||||
|
||||
this.element.addEventListener('mouseleave', () => {
|
||||
this.element.style.transform = 'rotateY(0) rotateX(0)';
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Problem | Fix |
|
||||
|---------|-----|
|
||||
| Fonts not loading | Check Fontshare/Google Fonts URL; ensure font names match in CSS |
|
||||
| Animations not triggering | Verify Intersection Observer is running; check `.visible` class is being added |
|
||||
| Scroll snap not working | Ensure `scroll-snap-type: y mandatory` on html; each slide needs `scroll-snap-align: start` |
|
||||
| Mobile issues | Disable heavy effects at 768px breakpoint; test touch events; reduce particle count |
|
||||
| Performance issues | Use `will-change` sparingly; prefer `transform`/`opacity` animations; throttle scroll handlers |
|
||||
@@ -0,0 +1,419 @@
|
||||
# HTML Presentation Template
|
||||
|
||||
Reference architecture for generating slide presentations. Every presentation follows this structure.
|
||||
|
||||
## Base HTML Structure
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Presentation Title</title>
|
||||
|
||||
<!-- Fonts: use Fontshare or Google Fonts — never system fonts -->
|
||||
<link rel="stylesheet" href="https://api.fontshare.com/v2/css?f[]=..." />
|
||||
|
||||
<style>
|
||||
/* ===========================================
|
||||
CSS CUSTOM PROPERTIES (THEME)
|
||||
Change these to change the whole look
|
||||
=========================================== */
|
||||
:root {
|
||||
/* Colors — from chosen style preset */
|
||||
--bg-primary: #0a0f1c;
|
||||
--bg-secondary: #111827;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #9ca3af;
|
||||
--accent: #00ffcc;
|
||||
--accent-glow: rgba(0, 255, 204, 0.3);
|
||||
|
||||
/* Typography — MUST use clamp() */
|
||||
--font-display: "Clash Display", sans-serif;
|
||||
--font-body: "Satoshi", sans-serif;
|
||||
--title-size: clamp(2rem, 6vw, 5rem);
|
||||
--subtitle-size: clamp(0.875rem, 2vw, 1.25rem);
|
||||
--body-size: clamp(0.75rem, 1.2vw, 1rem);
|
||||
|
||||
/* Spacing — MUST use clamp() */
|
||||
--slide-padding: clamp(1.5rem, 4vw, 4rem);
|
||||
--content-gap: clamp(1rem, 2vw, 2rem);
|
||||
|
||||
/* Animation */
|
||||
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--duration-normal: 0.6s;
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
BASE STYLES
|
||||
=========================================== */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* --- PASTE viewport-base.css CONTENTS HERE --- */
|
||||
|
||||
/* ===========================================
|
||||
ANIMATIONS
|
||||
Trigger via .visible class (added by JS on scroll)
|
||||
=========================================== */
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
transition:
|
||||
opacity var(--duration-normal) var(--ease-out-expo),
|
||||
transform var(--duration-normal) var(--ease-out-expo);
|
||||
}
|
||||
|
||||
.slide.visible .reveal {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Stagger children for sequential reveal */
|
||||
.reveal:nth-child(1) {
|
||||
transition-delay: 0.1s;
|
||||
}
|
||||
.reveal:nth-child(2) {
|
||||
transition-delay: 0.2s;
|
||||
}
|
||||
.reveal:nth-child(3) {
|
||||
transition-delay: 0.3s;
|
||||
}
|
||||
.reveal:nth-child(4) {
|
||||
transition-delay: 0.4s;
|
||||
}
|
||||
|
||||
/* ... preset-specific styles ... */
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Optional: Progress bar -->
|
||||
<div class="progress-bar"></div>
|
||||
|
||||
<!-- Optional: Navigation dots -->
|
||||
<nav class="nav-dots"><!-- Generated by JS --></nav>
|
||||
|
||||
<!-- Slides -->
|
||||
<section class="slide title-slide">
|
||||
<h1 class="reveal">Presentation Title</h1>
|
||||
<p class="reveal">Subtitle or author</p>
|
||||
</section>
|
||||
|
||||
<section class="slide">
|
||||
<div class="slide-content">
|
||||
<h2 class="reveal">Slide Title</h2>
|
||||
<p class="reveal">Content...</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- More slides... -->
|
||||
|
||||
<script>
|
||||
/* ===========================================
|
||||
SLIDE PRESENTATION CONTROLLER
|
||||
=========================================== */
|
||||
class SlidePresentation {
|
||||
constructor() {
|
||||
this.slides = document.querySelectorAll(".slide");
|
||||
this.currentSlide = 0;
|
||||
this.setupIntersectionObserver();
|
||||
this.setupKeyboardNav();
|
||||
this.setupTouchNav();
|
||||
this.setupProgressBar();
|
||||
this.setupNavDots();
|
||||
}
|
||||
|
||||
setupIntersectionObserver() {
|
||||
// Add .visible class when slides enter viewport
|
||||
// Triggers CSS animations efficiently
|
||||
}
|
||||
|
||||
setupKeyboardNav() {
|
||||
// Arrow keys, Space, Page Up/Down
|
||||
}
|
||||
|
||||
setupTouchNav() {
|
||||
// Touch/swipe support for mobile
|
||||
}
|
||||
|
||||
setupProgressBar() {
|
||||
// Update progress bar on scroll
|
||||
}
|
||||
|
||||
setupNavDots() {
|
||||
// IMPORTANT: Always clear before building — if outerHTML was
|
||||
// captured while dots were rendered, re-opening the file would
|
||||
// append a duplicate set on top of the existing ones.
|
||||
this.navDotsContainer.innerHTML = "";
|
||||
// Generate and manage navigation dots
|
||||
}
|
||||
}
|
||||
|
||||
new SlidePresentation();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## Required JavaScript Features
|
||||
|
||||
Every presentation must include:
|
||||
|
||||
1. **SlidePresentation Class** — Main controller with:
|
||||
- Keyboard navigation (arrows, space, page up/down)
|
||||
- Touch/swipe support
|
||||
- Mouse wheel navigation
|
||||
- Progress bar updates
|
||||
- Navigation dots
|
||||
|
||||
2. **Intersection Observer** — For scroll-triggered animations:
|
||||
- Add `.visible` class when slides enter viewport
|
||||
- Trigger CSS transitions efficiently
|
||||
|
||||
3. **Optional Enhancements** (match to chosen style):
|
||||
- Custom cursor with trail
|
||||
- Particle system background (canvas)
|
||||
- Parallax effects
|
||||
- 3D tilt on hover
|
||||
- Magnetic buttons
|
||||
- Counter animations
|
||||
|
||||
4. **Inline Editing** (only if user opted in during Phase 1 — skip entirely if they said No):
|
||||
- Edit toggle button (hidden by default, revealed via hover hotzone or `E` key)
|
||||
- Auto-save to localStorage
|
||||
- Export/save file functionality
|
||||
- See "Inline Editing Implementation" section below
|
||||
|
||||
## Inline Editing Implementation (Opt-In Only)
|
||||
|
||||
**If the user chose "No" for inline editing in Phase 1, do NOT generate any edit-related HTML, CSS, or JS.**
|
||||
|
||||
**Do NOT use CSS `~` sibling selector for hover-based show/hide.** The CSS-only approach (`edit-hotzone:hover ~ .edit-toggle`) fails because `pointer-events: none` on the toggle button breaks the hover chain: user hovers hotzone -> button becomes visible -> mouse moves toward button -> leaves hotzone -> button disappears before click.
|
||||
|
||||
**Required approach: JS-based hover with 400ms delay timeout.**
|
||||
|
||||
HTML:
|
||||
|
||||
```html
|
||||
<div class="edit-hotzone"></div>
|
||||
<button class="edit-toggle" id="editToggle" title="Edit mode (E)">Edit</button>
|
||||
```
|
||||
|
||||
CSS (visibility controlled by JS classes only):
|
||||
|
||||
```css
|
||||
/* Do NOT use CSS ~ sibling selector for this!
|
||||
pointer-events: none breaks the hover chain.
|
||||
Must use JS with delay timeout. */
|
||||
.edit-hotzone {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
z-index: 10000;
|
||||
cursor: pointer;
|
||||
}
|
||||
.edit-toggle {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease;
|
||||
z-index: 10001;
|
||||
}
|
||||
.edit-toggle.show,
|
||||
.edit-toggle.active {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
```
|
||||
|
||||
JS (three interaction methods):
|
||||
|
||||
```javascript
|
||||
// 1. Click handler on the toggle button
|
||||
document.getElementById("editToggle").addEventListener("click", () => {
|
||||
editor.toggleEditMode();
|
||||
});
|
||||
|
||||
// 2. Hotzone hover with 400ms grace period
|
||||
const hotzone = document.querySelector(".edit-hotzone");
|
||||
const editToggle = document.getElementById("editToggle");
|
||||
let hideTimeout = null;
|
||||
|
||||
hotzone.addEventListener("mouseenter", () => {
|
||||
clearTimeout(hideTimeout);
|
||||
editToggle.classList.add("show");
|
||||
});
|
||||
hotzone.addEventListener("mouseleave", () => {
|
||||
hideTimeout = setTimeout(() => {
|
||||
if (!editor.isActive) editToggle.classList.remove("show");
|
||||
}, 400);
|
||||
});
|
||||
editToggle.addEventListener("mouseenter", () => {
|
||||
clearTimeout(hideTimeout);
|
||||
});
|
||||
editToggle.addEventListener("mouseleave", () => {
|
||||
hideTimeout = setTimeout(() => {
|
||||
if (!editor.isActive) editToggle.classList.remove("show");
|
||||
}, 400);
|
||||
});
|
||||
|
||||
// 3. Hotzone direct click
|
||||
hotzone.addEventListener("click", () => {
|
||||
editor.toggleEditMode();
|
||||
});
|
||||
|
||||
// 4. Keyboard shortcut (E key, skip when editing text)
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (
|
||||
(e.key === "e" || e.key === "E") &&
|
||||
!e.target.getAttribute("contenteditable")
|
||||
) {
|
||||
editor.toggleEditMode();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**CRITICAL: `exportFile()` must strip edit state before capturing outerHTML.**
|
||||
|
||||
When the user presses Ctrl+S in edit mode, `document.documentElement.outerHTML` captures the live DOM —
|
||||
including `body.edit-active`, `contenteditable="true"` on every text element, and `.active`/`.show` classes on
|
||||
the toggle button and banner. Anyone opening the saved file sees dashed outlines, a checkmark button, and an
|
||||
edit banner, as if permanently stuck in edit mode.
|
||||
|
||||
Always implement `exportFile()` like this:
|
||||
|
||||
```javascript
|
||||
exportFile() {
|
||||
// Temporarily strip edit state so the saved file opens cleanly
|
||||
const editableEls = Array.from(document.querySelectorAll('[contenteditable]'));
|
||||
editableEls.forEach(el => el.removeAttribute('contenteditable'));
|
||||
document.body.classList.remove('edit-active');
|
||||
|
||||
// Also strip UI classes from toggle button and banner
|
||||
const editToggle = document.getElementById('editToggle');
|
||||
const editBanner = document.querySelector('.edit-banner');
|
||||
editToggle?.classList.remove('active', 'show');
|
||||
editBanner?.classList.remove('active', 'show');
|
||||
|
||||
const html = '<!DOCTYPE html>\n' + document.documentElement.outerHTML;
|
||||
|
||||
// Restore edit state so the user can keep editing
|
||||
document.body.classList.add('edit-active');
|
||||
editableEls.forEach(el => el.setAttribute('contenteditable', 'true'));
|
||||
editToggle?.classList.add('active');
|
||||
editBanner?.classList.add('active');
|
||||
|
||||
const blob = new Blob([html], { type: 'text/html' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'presentation.html';
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
```
|
||||
|
||||
## Image Pipeline (Skip If No Images)
|
||||
|
||||
If user chose "No images" in Phase 1, skip this entirely. If images were provided, process them before generating HTML.
|
||||
|
||||
**Dependency:** `pip install Pillow`
|
||||
|
||||
### Image Processing
|
||||
|
||||
```python
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
# Circular crop (for logos on modern/clean styles)
|
||||
def crop_circle(input_path, output_path):
|
||||
img = Image.open(input_path).convert('RGBA')
|
||||
w, h = img.size
|
||||
size = min(w, h)
|
||||
left, top = (w - size) // 2, (h - size) // 2
|
||||
img = img.crop((left, top, left + size, top + size))
|
||||
mask = Image.new('L', (size, size), 0)
|
||||
ImageDraw.Draw(mask).ellipse([0, 0, size, size], fill=255)
|
||||
img.putalpha(mask)
|
||||
img.save(output_path, 'PNG')
|
||||
|
||||
# Resize (for oversized images that inflate HTML)
|
||||
def resize_max(input_path, output_path, max_dim=1200):
|
||||
img = Image.open(input_path)
|
||||
img.thumbnail((max_dim, max_dim), Image.LANCZOS)
|
||||
img.save(output_path, quality=85)
|
||||
```
|
||||
|
||||
| Situation | Operation |
|
||||
| -------------------------------- | ----------------------------- |
|
||||
| Square logo on rounded aesthetic | `crop_circle()` |
|
||||
| Image > 1MB | `resize_max(max_dim=1200)` |
|
||||
| Wrong aspect ratio | Manual crop with `img.crop()` |
|
||||
|
||||
Save processed images with `_processed` suffix. Never overwrite originals.
|
||||
|
||||
### Image Placement
|
||||
|
||||
**Use direct file paths** (not base64) — presentations are viewed locally:
|
||||
|
||||
```html
|
||||
<img src="assets/logo_round.png" alt="Logo" class="slide-image logo" />
|
||||
<img
|
||||
src="assets/screenshot.png"
|
||||
alt="Screenshot"
|
||||
class="slide-image screenshot"
|
||||
/>
|
||||
```
|
||||
|
||||
```css
|
||||
.slide-image {
|
||||
max-width: 100%;
|
||||
max-height: min(50vh, 400px);
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.slide-image.screenshot {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.slide-image.logo {
|
||||
max-height: min(30vh, 200px);
|
||||
}
|
||||
```
|
||||
|
||||
**Adapt border/shadow colors to match the chosen style's accent.** Never repeat the same image on multiple slides (except logos on title + closing).
|
||||
|
||||
**Placement patterns:** Logo centered on title slide. Screenshots in two-column layouts with text. Full-bleed images as slide backgrounds with text overlay (use sparingly).
|
||||
|
||||
---
|
||||
|
||||
## Code Quality
|
||||
|
||||
**Comments:** Every section needs clear comments explaining what it does and how to modify it.
|
||||
|
||||
**Accessibility:**
|
||||
|
||||
- Semantic HTML (`<section>`, `<nav>`, `<main>`)
|
||||
- Keyboard navigation works fully
|
||||
- ARIA labels where needed
|
||||
- `prefers-reduced-motion` support (included in viewport-base.css)
|
||||
|
||||
## File Structure
|
||||
|
||||
Single presentations:
|
||||
|
||||
```
|
||||
presentation.html # Self-contained, all CSS/JS inline
|
||||
assets/ # Images only, if any
|
||||
```
|
||||
|
||||
Multiple presentations in one project:
|
||||
|
||||
```
|
||||
[name].html
|
||||
[name]-assets/
|
||||
```
|
||||
Executable
+418
@@ -0,0 +1,418 @@
|
||||
#!/usr/bin/env bash
|
||||
# export-pdf.sh - Export an HTML presentation to PDF
|
||||
#
|
||||
# Usage:
|
||||
# bash scripts/export-pdf.sh <path-to-html> [output.pdf]
|
||||
#
|
||||
# Examples:
|
||||
# bash scripts/export-pdf.sh ./my-deck/index.html
|
||||
# bash scripts/export-pdf.sh ./presentation.html ./presentation.pdf
|
||||
#
|
||||
# What this does:
|
||||
# 1. Starts a local server to serve the HTML (fonts and assets need HTTP)
|
||||
# 2. Uses Playwright to screenshot each slide at 1920x1080
|
||||
# 3. Combines all screenshots into a single PDF
|
||||
# 4. Cleans up the server and temp files
|
||||
#
|
||||
# The PDF preserves colors, fonts, and layout - but not animations.
|
||||
# Perfect for email attachments, printing, or embedding in documents.
|
||||
set -euo pipefail
|
||||
|
||||
# --- Colors ---
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
CYAN='\033[0;36m'
|
||||
YELLOW='\033[1;33m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
info() { echo -e "${CYAN}INFO:${NC} $*"; }
|
||||
ok() { echo -e "${GREEN}OK:${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}WARNING:${NC} $*"; }
|
||||
err() { echo -e "${RED}ERROR:${NC} $*" >&2; }
|
||||
|
||||
# --- Parse flags ---
|
||||
|
||||
# Default resolution: 1920x1080 (full HD, ~1-2MB per slide)
|
||||
# Compact resolution: 1280x720 (HD, ~50-70% smaller files)
|
||||
VIEWPORT_W=1920
|
||||
VIEWPORT_H=1080
|
||||
COMPACT=false
|
||||
|
||||
POSITIONAL=()
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--compact)
|
||||
COMPACT=true
|
||||
VIEWPORT_W=1280
|
||||
VIEWPORT_H=720
|
||||
;;
|
||||
*)
|
||||
POSITIONAL+=("$arg")
|
||||
;;
|
||||
esac
|
||||
done
|
||||
set -- "${POSITIONAL[@]}"
|
||||
|
||||
# --- Input validation ---
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
err "Usage: bash scripts/export-pdf.sh <path-to-html> [output.pdf] [--compact]"
|
||||
err ""
|
||||
err "Examples:"
|
||||
err " bash scripts/export-pdf.sh ./my-deck/index.html"
|
||||
err " bash scripts/export-pdf.sh ./presentation.html ./slides.pdf"
|
||||
err " bash scripts/export-pdf.sh ./presentation.html --compact # smaller file size"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
INPUT_HTML="$1"
|
||||
if [[ ! -f "$INPUT_HTML" ]]; then
|
||||
err "File not found: $INPUT_HTML"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Resolve to absolute path
|
||||
INPUT_HTML=$(cd "$(dirname "$INPUT_HTML")" && pwd)/$(basename "$INPUT_HTML")
|
||||
|
||||
# Output PDF path: use second argument or derive from input name
|
||||
if [[ $# -ge 2 ]]; then
|
||||
OUTPUT_PDF="$2"
|
||||
else
|
||||
OUTPUT_PDF="$(dirname "$INPUT_HTML")/$(basename "$INPUT_HTML" .html).pdf"
|
||||
fi
|
||||
|
||||
# Resolve output to absolute path
|
||||
OUTPUT_DIR=$(dirname "$OUTPUT_PDF")
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
OUTPUT_PDF="$OUTPUT_DIR/$(basename "$OUTPUT_PDF")"
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}========================================${NC}"
|
||||
echo -e "${BOLD} Export Slides to PDF${NC}"
|
||||
echo -e "${BOLD}========================================${NC}"
|
||||
echo ""
|
||||
|
||||
# --- Step 1: Check dependencies ---
|
||||
|
||||
info "Checking dependencies..."
|
||||
|
||||
if ! command -v npx &>/dev/null; then
|
||||
err "Node.js is required but not installed."
|
||||
err ""
|
||||
err "Install Node.js:"
|
||||
err " macOS: brew install node"
|
||||
err " or visit https://nodejs.org and download the installer"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ok "Node.js found"
|
||||
|
||||
# --- Step 2: Create the export script ---
|
||||
|
||||
# We use a temporary Node.js script with Playwright to:
|
||||
# 1. Start a local server (so fonts load correctly)
|
||||
# 2. Navigate to each slide
|
||||
# 3. Screenshot each slide at 1920x1080 (16:9 landscape)
|
||||
# 4. Combine into a single PDF
|
||||
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
TEMP_SCRIPT="$TEMP_DIR/export-slides.mjs"
|
||||
|
||||
# Figure out which directory to serve (the folder containing the HTML)
|
||||
SERVE_DIR=$(dirname "$INPUT_HTML")
|
||||
HTML_FILENAME=$(basename "$INPUT_HTML")
|
||||
|
||||
cat > "$TEMP_SCRIPT" << 'EXPORT_SCRIPT'
|
||||
// export-slides.mjs - Playwright script to export HTML slides to PDF
|
||||
//
|
||||
// How it works:
|
||||
// 1. Starts a local HTTP server (needed for fonts/assets to load)
|
||||
// 2. Opens the presentation in a headless browser at 1920x1080
|
||||
// 3. Counts the total number of slides
|
||||
// 4. Screenshots each slide one by one
|
||||
// 5. Generates a PDF with all slides as landscape pages
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
import { createServer } from 'http';
|
||||
import { readFileSync, existsSync, mkdirSync, unlinkSync, writeFileSync } from 'fs';
|
||||
import { join, extname, resolve } from 'path';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
const SERVE_DIR = process.argv[2];
|
||||
const HTML_FILE = process.argv[3];
|
||||
const OUTPUT_PDF = process.argv[4];
|
||||
const SCREENSHOT_DIR = process.argv[5];
|
||||
const VP_WIDTH = parseInt(process.argv[6]) || 1920;
|
||||
const VP_HEIGHT = parseInt(process.argv[7]) || 1080;
|
||||
|
||||
// --- Simple static file server ---
|
||||
// (We need HTTP so that Google Fonts and relative assets load correctly)
|
||||
|
||||
const MIME_TYPES = {
|
||||
'.html': 'text/html',
|
||||
'.css': 'text/css',
|
||||
'.js': 'application/javascript',
|
||||
'.json': 'application/json',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.webp': 'image/webp',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.ttf': 'font/ttf',
|
||||
'.eot': 'application/vnd.ms-fontobject',
|
||||
};
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
// Decode URL-encoded characters (e.g., %20 -> space) so filenames with spaces resolve correctly
|
||||
const decodedUrl = decodeURIComponent(req.url);
|
||||
let filePath = join(SERVE_DIR, decodedUrl === '/' ? HTML_FILE : decodedUrl);
|
||||
try {
|
||||
const content = readFileSync(filePath);
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
res.writeHead(200, { 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream' });
|
||||
res.end(content);
|
||||
} catch {
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
}
|
||||
});
|
||||
|
||||
// Find a free port
|
||||
const port = await new Promise((resolve) => {
|
||||
server.listen(0, () => resolve(server.address().port));
|
||||
});
|
||||
|
||||
console.log(` Local server on port ${port}`);
|
||||
|
||||
// --- Screenshot each slide ---
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: VP_WIDTH, height: VP_HEIGHT },
|
||||
});
|
||||
|
||||
// Load the presentation
|
||||
await page.goto(`http://localhost:${port}/`, { waitUntil: 'networkidle' });
|
||||
|
||||
// Wait for fonts to load
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
|
||||
// Extra wait for animations to settle on the first slide
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Count slides
|
||||
const slideCount = await page.evaluate(() => {
|
||||
return document.querySelectorAll('.slide').length;
|
||||
});
|
||||
|
||||
console.log(` Found ${slideCount} slides`);
|
||||
|
||||
if (slideCount === 0) {
|
||||
console.error(' ERROR: No .slide elements found in the presentation.');
|
||||
console.error(' Make sure your HTML uses <div class="slide"> or <section class="slide">.');
|
||||
await browser.close();
|
||||
server.close();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Screenshot each slide
|
||||
mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||||
const screenshotPaths = [];
|
||||
|
||||
for (let i = 0; i < slideCount; i++) {
|
||||
// Navigate to slide by simulating the presentation's navigation
|
||||
// Most frontend-slides presentations use a currentSlide index and show/hide
|
||||
await page.evaluate((index) => {
|
||||
const slides = document.querySelectorAll('.slide');
|
||||
|
||||
// Try multiple navigation strategies used by frontend-slides:
|
||||
|
||||
// Strategy 1: Direct slide manipulation (most common in generated decks)
|
||||
slides.forEach((slide, idx) => {
|
||||
if (idx === index) {
|
||||
slide.style.display = '';
|
||||
slide.style.opacity = '1';
|
||||
slide.style.visibility = 'visible';
|
||||
slide.style.position = 'relative';
|
||||
slide.style.transform = 'none';
|
||||
slide.classList.add('active');
|
||||
} else {
|
||||
slide.style.display = 'none';
|
||||
slide.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Strategy 2: If there's a SlidePresentation class instance, use it
|
||||
if (window.presentation && typeof window.presentation.goToSlide === 'function') {
|
||||
window.presentation.goToSlide(index);
|
||||
}
|
||||
|
||||
// Strategy 3: Scroll-based (some decks use scroll snapping)
|
||||
slides[index]?.scrollIntoView({ behavior: 'instant' });
|
||||
}, i);
|
||||
|
||||
// Wait for any slide transition animations to finish
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Wait for intersection observer animations to trigger
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Force all .reveal elements on the current slide to be visible
|
||||
// (animations normally trigger on scroll/intersection, but we need them visible now)
|
||||
await page.evaluate((index) => {
|
||||
const slides = document.querySelectorAll('.slide');
|
||||
const currentSlide = slides[index];
|
||||
if (currentSlide) {
|
||||
currentSlide.querySelectorAll('.reveal').forEach(el => {
|
||||
el.style.opacity = '1';
|
||||
el.style.transform = 'none';
|
||||
el.style.visibility = 'visible';
|
||||
});
|
||||
}
|
||||
}, i);
|
||||
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
const screenshotPath = join(SCREENSHOT_DIR, `slide-${String(i + 1).padStart(3, '0')}.png`);
|
||||
await page.screenshot({ path: screenshotPath, fullPage: false });
|
||||
screenshotPaths.push(screenshotPath);
|
||||
console.log(` Captured slide ${i + 1}/${slideCount}`);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
server.close();
|
||||
|
||||
// --- Combine screenshots into PDF ---
|
||||
// Use a second Playwright page to generate a PDF from the screenshots
|
||||
|
||||
console.log(' Assembling PDF...');
|
||||
|
||||
const browser2 = await chromium.launch();
|
||||
const pdfPage = await browser2.newPage();
|
||||
|
||||
// Build an HTML page with all screenshots, one per page
|
||||
const imagesHtml = screenshotPaths.map((p) => {
|
||||
const imgData = readFileSync(p).toString('base64');
|
||||
return `<div class="page"><img src="data:image/png;base64,${imgData}" /></div>`;
|
||||
}).join('\n');
|
||||
|
||||
const pdfHtml = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; }
|
||||
@page { size: ${VP_WIDTH}px ${VP_HEIGHT}px; margin: 0; }
|
||||
.page {
|
||||
width: ${VP_WIDTH}px;
|
||||
height: ${VP_HEIGHT}px;
|
||||
page-break-after: always;
|
||||
overflow: hidden;
|
||||
}
|
||||
.page:last-child { page-break-after: auto; }
|
||||
img {
|
||||
width: ${VP_WIDTH}px;
|
||||
height: ${VP_HEIGHT}px;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>${imagesHtml}</body>
|
||||
</html>`;
|
||||
|
||||
await pdfPage.setContent(pdfHtml, { waitUntil: 'load' });
|
||||
await pdfPage.pdf({
|
||||
path: OUTPUT_PDF,
|
||||
width: `${VP_WIDTH}px`,
|
||||
height: `${VP_HEIGHT}px`,
|
||||
printBackground: true,
|
||||
margin: { top: 0, right: 0, bottom: 0, left: 0 },
|
||||
});
|
||||
|
||||
await browser2.close();
|
||||
|
||||
// Clean up screenshots
|
||||
screenshotPaths.forEach(p => unlinkSync(p));
|
||||
|
||||
console.log(` OK: PDF saved to: ${OUTPUT_PDF}`);
|
||||
EXPORT_SCRIPT
|
||||
|
||||
# --- Step 3: Install Playwright in temp directory ---
|
||||
# We install Playwright locally in the temp dir so the Node script can import it.
|
||||
# This avoids polluting global packages and ensures the script is self-contained.
|
||||
|
||||
info "Setting up Playwright (headless browser for screenshots)..."
|
||||
info "This may take a moment on first run..."
|
||||
echo ""
|
||||
|
||||
cd "$TEMP_DIR"
|
||||
|
||||
# Create a minimal package.json so npm install works
|
||||
cat > "$TEMP_DIR/package.json" << 'PKG'
|
||||
{ "name": "slide-export", "private": true, "type": "module" }
|
||||
PKG
|
||||
|
||||
# Install Playwright into the temp directory
|
||||
npm install playwright &>/dev/null || {
|
||||
err "Failed to install Playwright."
|
||||
err "Try running: npm install playwright"
|
||||
rm -rf "$TEMP_DIR"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Ensure Chromium browser binary is downloaded
|
||||
npx playwright install chromium 2>/dev/null || {
|
||||
err "Failed to install Chromium browser for Playwright."
|
||||
err "Try running manually: npx playwright install chromium"
|
||||
rm -rf "$TEMP_DIR"
|
||||
exit 1
|
||||
}
|
||||
ok "Playwright ready"
|
||||
echo ""
|
||||
|
||||
# --- Step 4: Run the export ---
|
||||
|
||||
SCREENSHOT_DIR="$TEMP_DIR/screenshots"
|
||||
|
||||
info "Exporting slides to PDF..."
|
||||
echo ""
|
||||
|
||||
# Run from the temp dir so Node can find the locally-installed playwright
|
||||
if [[ "$COMPACT" == "true" ]]; then
|
||||
info "Using compact mode (1280x720) for smaller file size"
|
||||
fi
|
||||
|
||||
node "$TEMP_SCRIPT" "$SERVE_DIR" "$HTML_FILENAME" "$OUTPUT_PDF" "$SCREENSHOT_DIR" "$VIEWPORT_W" "$VIEWPORT_H" || {
|
||||
err "PDF export failed."
|
||||
rm -rf "$TEMP_DIR"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Step 5: Cleanup and success ---
|
||||
|
||||
rm -rf "$TEMP_DIR"
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}========================================${NC}"
|
||||
ok "PDF exported successfully!"
|
||||
echo ""
|
||||
echo -e " ${BOLD}File:${NC} $OUTPUT_PDF"
|
||||
echo ""
|
||||
FILE_SIZE=$(du -h "$OUTPUT_PDF" | cut -f1 | xargs)
|
||||
echo " Size: $FILE_SIZE"
|
||||
echo ""
|
||||
echo " This PDF works everywhere - email, Slack, Notion, print."
|
||||
echo " Note: Animations are not preserved (it's a static export)."
|
||||
echo -e "${BOLD}========================================${NC}"
|
||||
echo ""
|
||||
|
||||
# Open the PDF automatically
|
||||
if command -v open &>/dev/null; then
|
||||
open "$OUTPUT_PDF"
|
||||
elif command -v xdg-open &>/dev/null; then
|
||||
xdg-open "$OUTPUT_PDF"
|
||||
fi
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Extract all content from a PowerPoint file (.pptx).
|
||||
Returns a JSON structure with slides, text, and images.
|
||||
|
||||
Usage:
|
||||
python extract-pptx.py <input.pptx> [output_dir]
|
||||
|
||||
Requires: pip install python-pptx
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pptx import Presentation
|
||||
|
||||
|
||||
def extract_pptx(file_path, output_dir="."):
|
||||
"""
|
||||
Extract all content from a PowerPoint file.
|
||||
Returns a list of slide data dicts with text, images, and notes.
|
||||
"""
|
||||
prs = Presentation(file_path)
|
||||
slides_data = []
|
||||
|
||||
# Create assets directory for extracted images
|
||||
assets_dir = os.path.join(output_dir, "assets")
|
||||
os.makedirs(assets_dir, exist_ok=True)
|
||||
|
||||
for slide_num, slide in enumerate(prs.slides):
|
||||
slide_data = {
|
||||
"number": slide_num + 1,
|
||||
"title": "",
|
||||
"content": [],
|
||||
"images": [],
|
||||
"notes": "",
|
||||
}
|
||||
|
||||
for shape in slide.shapes:
|
||||
# Extract text content
|
||||
if shape.has_text_frame:
|
||||
if shape == slide.shapes.title:
|
||||
slide_data["title"] = shape.text
|
||||
else:
|
||||
slide_data["content"].append(
|
||||
{"type": "text", "content": shape.text}
|
||||
)
|
||||
|
||||
# Extract images
|
||||
if shape.shape_type == 13: # Picture type
|
||||
image = shape.image
|
||||
image_bytes = image.blob
|
||||
image_ext = image.ext
|
||||
image_name = f"slide{slide_num + 1}_img{len(slide_data['images']) + 1}.{image_ext}"
|
||||
image_path = os.path.join(assets_dir, image_name)
|
||||
|
||||
with open(image_path, "wb") as f:
|
||||
f.write(image_bytes)
|
||||
|
||||
slide_data["images"].append(
|
||||
{
|
||||
"path": f"assets/{image_name}",
|
||||
"width": shape.width,
|
||||
"height": shape.height,
|
||||
}
|
||||
)
|
||||
|
||||
# Extract speaker notes
|
||||
if slide.has_notes_slide:
|
||||
notes_frame = slide.notes_slide.notes_text_frame
|
||||
slide_data["notes"] = notes_frame.text
|
||||
|
||||
slides_data.append(slide_data)
|
||||
|
||||
return slides_data
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python extract-pptx.py <input.pptx> [output_dir]")
|
||||
sys.exit(1)
|
||||
|
||||
input_file = sys.argv[1]
|
||||
output_dir = sys.argv[2] if len(sys.argv) > 2 else "."
|
||||
|
||||
slides = extract_pptx(input_file, output_dir)
|
||||
|
||||
# Write extracted data as JSON
|
||||
output_path = os.path.join(output_dir, "extracted-slides.json")
|
||||
with open(output_path, "w") as f:
|
||||
json.dump(slides, f, indent=2)
|
||||
|
||||
print(f"Extracted {len(slides)} slides to {output_path}")
|
||||
for s in slides:
|
||||
img_count = len(s["images"])
|
||||
print(f" Slide {s['number']}: {s['title'] or '(no title)'} — {img_count} image(s)")
|
||||
@@ -0,0 +1,153 @@
|
||||
/* ===========================================
|
||||
VIEWPORT FITTING: MANDATORY BASE STYLES
|
||||
Include this ENTIRE file in every presentation.
|
||||
These styles ensure slides fit exactly in the viewport.
|
||||
=========================================== */
|
||||
|
||||
/* 1. Lock html/body to viewport */
|
||||
html, body {
|
||||
height: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-snap-type: y mandatory;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* 2. Each slide = exact viewport height */
|
||||
.slide {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
height: 100dvh; /* Dynamic viewport height for mobile browsers */
|
||||
overflow: hidden; /* CRITICAL: Prevent ANY overflow */
|
||||
scroll-snap-align: start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 3. Content container with flex for centering */
|
||||
.slide-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
max-height: 100%;
|
||||
overflow: hidden; /* Double-protection against overflow */
|
||||
padding: var(--slide-padding);
|
||||
}
|
||||
|
||||
/* 4. ALL typography uses clamp() for responsive scaling */
|
||||
:root {
|
||||
/* Titles scale from mobile to desktop */
|
||||
--title-size: clamp(1.5rem, 5vw, 4rem);
|
||||
--h2-size: clamp(1.25rem, 3.5vw, 2.5rem);
|
||||
--h3-size: clamp(1rem, 2.5vw, 1.75rem);
|
||||
|
||||
/* Body text */
|
||||
--body-size: clamp(0.75rem, 1.5vw, 1.125rem);
|
||||
--small-size: clamp(0.65rem, 1vw, 0.875rem);
|
||||
|
||||
/* Spacing scales with viewport */
|
||||
--slide-padding: clamp(1rem, 4vw, 4rem);
|
||||
--content-gap: clamp(0.5rem, 2vw, 2rem);
|
||||
--element-gap: clamp(0.25rem, 1vw, 1rem);
|
||||
}
|
||||
|
||||
/* 5. Cards/containers use viewport-relative max sizes */
|
||||
.card, .container, .content-box {
|
||||
max-width: min(90vw, 1000px);
|
||||
max-height: min(80vh, 700px);
|
||||
}
|
||||
|
||||
/* 6. Lists auto-scale with viewport */
|
||||
.feature-list, .bullet-list {
|
||||
gap: clamp(0.4rem, 1vh, 1rem);
|
||||
}
|
||||
|
||||
.feature-list li, .bullet-list li {
|
||||
font-size: var(--body-size);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* 7. Grids adapt to available space */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 250px), 1fr));
|
||||
gap: clamp(0.5rem, 1.5vw, 1rem);
|
||||
}
|
||||
|
||||
/* 8. Images constrained to viewport */
|
||||
img, .image-container {
|
||||
max-width: 100%;
|
||||
max-height: min(50vh, 400px);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
RESPONSIVE BREAKPOINTS
|
||||
Aggressive scaling for smaller viewports
|
||||
=========================================== */
|
||||
|
||||
/* Short viewports (< 700px height) */
|
||||
@media (max-height: 700px) {
|
||||
:root {
|
||||
--slide-padding: clamp(0.75rem, 3vw, 2rem);
|
||||
--content-gap: clamp(0.4rem, 1.5vw, 1rem);
|
||||
--title-size: clamp(1.25rem, 4.5vw, 2.5rem);
|
||||
--h2-size: clamp(1rem, 3vw, 1.75rem);
|
||||
}
|
||||
}
|
||||
|
||||
/* Very short viewports (< 600px height) */
|
||||
@media (max-height: 600px) {
|
||||
:root {
|
||||
--slide-padding: clamp(0.5rem, 2.5vw, 1.5rem);
|
||||
--content-gap: clamp(0.3rem, 1vw, 0.75rem);
|
||||
--title-size: clamp(1.1rem, 4vw, 2rem);
|
||||
--body-size: clamp(0.7rem, 1.2vw, 0.95rem);
|
||||
}
|
||||
|
||||
/* Hide non-essential elements */
|
||||
.nav-dots, .keyboard-hint, .decorative {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Extremely short (landscape phones, < 500px height) */
|
||||
@media (max-height: 500px) {
|
||||
:root {
|
||||
--slide-padding: clamp(0.4rem, 2vw, 1rem);
|
||||
--title-size: clamp(1rem, 3.5vw, 1.5rem);
|
||||
--h2-size: clamp(0.9rem, 2.5vw, 1.25rem);
|
||||
--body-size: clamp(0.65rem, 1vw, 0.85rem);
|
||||
}
|
||||
}
|
||||
|
||||
/* Narrow viewports (< 600px width) */
|
||||
@media (max-width: 600px) {
|
||||
:root {
|
||||
--title-size: clamp(1.25rem, 7vw, 2.5rem);
|
||||
}
|
||||
|
||||
/* Stack grids vertically */
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
REDUCED MOTION
|
||||
Respect user preferences
|
||||
=========================================== */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.2s !important;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
---
|
||||
name: redis-patterns
|
||||
description: Redis data structure patterns, caching strategies, distributed locks, rate limiting, pub/sub, and connection management for production applications.
|
||||
origin: ECC
|
||||
---
|
||||
|
||||
# Redis Patterns
|
||||
|
||||
Quick reference for Redis best practices across common backend use cases.
|
||||
|
||||
## How It Works
|
||||
|
||||
Redis is an in-memory data structure store that supports strings, hashes, lists, sets, sorted sets, streams, and more. Individual Redis commands are atomic on a single instance; multi-step workflows require Lua scripts, MULTI/EXEC transactions, or explicit synchronization to stay atomic. Data is optionally persisted via RDB snapshots or AOF logs. Clients communicate over TCP using the RESP protocol; connection pools are essential to avoid per-request handshake overhead.
|
||||
|
||||
## When to Activate
|
||||
|
||||
- Adding caching to an application
|
||||
- Implementing rate limiting or throttling
|
||||
- Building distributed locks or coordination
|
||||
- Setting up session or token storage
|
||||
- Using Pub/Sub or Redis Streams for messaging
|
||||
- Configuring Redis in production (pooling, eviction, clustering)
|
||||
|
||||
## Data Structure Cheat Sheet
|
||||
|
||||
| Use Case | Structure | Example Key |
|
||||
|----------|-----------|-------------|
|
||||
| Simple cache | String | `product:123` |
|
||||
| User session | Hash | `session:abc` |
|
||||
| Leaderboard | Sorted Set | `scores:weekly` |
|
||||
| Unique visitors | Set | `visitors:2024-01-01` |
|
||||
| Activity feed | List | `feed:user:456` |
|
||||
| Event stream | Stream | `events:orders` |
|
||||
| Counters / rate limits | String (INCR) | `ratelimit:user:123` |
|
||||
| Bloom filter / HLL | HyperLogLog | `hll:pageviews` |
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Cache-Aside (Lazy Loading)
|
||||
|
||||
```python
|
||||
import redis
|
||||
import json
|
||||
|
||||
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
|
||||
|
||||
def get_product(product_id: int):
|
||||
cache_key = f"product:{product_id}"
|
||||
cached = r.get(cache_key)
|
||||
|
||||
if cached:
|
||||
return json.loads(cached)
|
||||
|
||||
product = db.query("SELECT * FROM products WHERE id = %s", product_id)
|
||||
r.setex(cache_key, 3600, json.dumps(product)) # TTL: 1 hour
|
||||
return product
|
||||
```
|
||||
|
||||
### Write-Through Cache
|
||||
|
||||
```python
|
||||
def update_product(product_id: int, data: dict):
|
||||
# Write to DB first
|
||||
db.execute("UPDATE products SET ... WHERE id = %s", product_id)
|
||||
|
||||
# Immediately update cache
|
||||
cache_key = f"product:{product_id}"
|
||||
r.setex(cache_key, 3600, json.dumps(data))
|
||||
```
|
||||
|
||||
### Cache Invalidation
|
||||
|
||||
```python
|
||||
# Tag-based invalidation — group related keys under a set
|
||||
def cache_product(product_id: int, category_id: int, data: dict):
|
||||
key = f"product:{product_id}"
|
||||
tag = f"tag:category:{category_id}"
|
||||
pipe = r.pipeline(transaction=True)
|
||||
pipe.setex(key, 3600, json.dumps(data))
|
||||
pipe.sadd(tag, key)
|
||||
pipe.expire(tag, 3600)
|
||||
pipe.execute()
|
||||
|
||||
def invalidate_category(category_id: int):
|
||||
tag = f"tag:category:{category_id}"
|
||||
keys = r.smembers(tag)
|
||||
if keys:
|
||||
r.delete(*keys)
|
||||
r.delete(tag)
|
||||
```
|
||||
|
||||
### Session Storage
|
||||
|
||||
```python
|
||||
import time
|
||||
import uuid
|
||||
|
||||
def create_session(user_id: int, ttl: int = 86400) -> str:
|
||||
session_id = str(uuid.uuid4())
|
||||
key = f"session:{session_id}"
|
||||
pipe = r.pipeline(transaction=True)
|
||||
pipe.hset(key, mapping={
|
||||
"user_id": user_id,
|
||||
"created_at": int(time.time()),
|
||||
})
|
||||
pipe.expire(key, ttl)
|
||||
pipe.execute()
|
||||
return session_id
|
||||
|
||||
def get_session(session_id: str) -> dict | None:
|
||||
data = r.hgetall(f"session:{session_id}")
|
||||
return data if data else None
|
||||
|
||||
def delete_session(session_id: str):
|
||||
r.delete(f"session:{session_id}")
|
||||
```
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
### Fixed Window (Simple)
|
||||
|
||||
```python
|
||||
def is_rate_limited(user_id: int, limit: int = 100, window: int = 60) -> bool:
|
||||
key = f"ratelimit:{user_id}:{int(time.time()) // window}"
|
||||
pipe = r.pipeline(transaction=True)
|
||||
pipe.incr(key)
|
||||
pipe.expire(key, window)
|
||||
count, _ = pipe.execute()
|
||||
return count > limit
|
||||
```
|
||||
|
||||
### Sliding Window (Lua — Atomic)
|
||||
|
||||
```lua
|
||||
-- sliding_window.lua
|
||||
local key = KEYS[1]
|
||||
local now = tonumber(ARGV[1])
|
||||
local window = tonumber(ARGV[2])
|
||||
local limit = tonumber(ARGV[3])
|
||||
|
||||
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
|
||||
local count = redis.call('ZCARD', key)
|
||||
|
||||
if count < limit then
|
||||
-- Use unique member (now + sequence) to avoid collisions within the same millisecond
|
||||
local seq_key = key .. ':seq'
|
||||
local seq = redis.call('INCR', seq_key)
|
||||
redis.call('EXPIRE', seq_key, math.ceil(window / 1000))
|
||||
redis.call('ZADD', key, now, now .. '-' .. seq)
|
||||
redis.call('EXPIRE', key, math.ceil(window / 1000))
|
||||
return 1
|
||||
end
|
||||
return 0
|
||||
```
|
||||
|
||||
```python
|
||||
sliding_window = r.register_script(open('sliding_window.lua').read())
|
||||
|
||||
def allow_request(user_id: int) -> bool:
|
||||
key = f"ratelimit:sliding:{user_id}"
|
||||
now = int(time.time() * 1000)
|
||||
return bool(sliding_window(keys=[key], args=[now, 60000, 100]))
|
||||
```
|
||||
|
||||
## Distributed Locks
|
||||
|
||||
### Distributed Lock (Single Node — SET NX PX)
|
||||
|
||||
```python
|
||||
import uuid
|
||||
|
||||
def acquire_lock(resource: str, ttl_ms: int = 5000) -> str | None:
|
||||
lock_key = f"lock:{resource}"
|
||||
token = str(uuid.uuid4())
|
||||
acquired = r.set(lock_key, token, px=ttl_ms, nx=True)
|
||||
return token if acquired else None
|
||||
|
||||
def release_lock(resource: str, token: str) -> bool:
|
||||
release_script = """
|
||||
if redis.call('get', KEYS[1]) == ARGV[1] then
|
||||
return redis.call('del', KEYS[1])
|
||||
else
|
||||
return 0
|
||||
end
|
||||
"""
|
||||
result = r.eval(release_script, 1, f"lock:{resource}", token)
|
||||
return bool(result)
|
||||
|
||||
# Usage
|
||||
token = acquire_lock("order:payment:123")
|
||||
if token:
|
||||
try:
|
||||
process_payment()
|
||||
finally:
|
||||
release_lock("order:payment:123", token)
|
||||
```
|
||||
|
||||
> For multi-node setups use the `redlock-py` library which implements the full Redlock algorithm.
|
||||
|
||||
## Pub/Sub & Streams
|
||||
|
||||
### Pub/Sub (Fire-and-Forget)
|
||||
|
||||
```python
|
||||
# Publisher
|
||||
def publish_event(channel: str, payload: dict):
|
||||
r.publish(channel, json.dumps(payload))
|
||||
|
||||
# Subscriber (blocking — run in separate thread/process)
|
||||
def subscribe_events(channel: str):
|
||||
pubsub = r.pubsub()
|
||||
pubsub.subscribe(channel)
|
||||
for message in pubsub.listen():
|
||||
if message['type'] == 'message':
|
||||
handle(json.loads(message['data']))
|
||||
```
|
||||
|
||||
### Redis Streams (Durable Queue)
|
||||
|
||||
```python
|
||||
# Producer
|
||||
def emit(stream: str, event: dict):
|
||||
r.xadd(stream, event, maxlen=10000) # Cap stream length
|
||||
|
||||
# Consumer group — guarantees at-least-once delivery
|
||||
try:
|
||||
r.xgroup_create('events:orders', 'processor', id='0', mkstream=True)
|
||||
except Exception:
|
||||
pass # Group already exists
|
||||
|
||||
def consume(stream: str, group: str, consumer: str):
|
||||
while True:
|
||||
messages = r.xreadgroup(group, consumer, {stream: '>'}, count=10, block=2000)
|
||||
for _, entries in (messages or []):
|
||||
for msg_id, data in entries:
|
||||
process(data)
|
||||
r.xack(stream, group, msg_id)
|
||||
```
|
||||
|
||||
> Prefer **Streams** over Pub/Sub when you need delivery guarantees, consumer groups, or replay.
|
||||
|
||||
## Key Design
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
```
|
||||
# Pattern: resource:id:field
|
||||
user:123:profile
|
||||
order:456:status
|
||||
cache:product:789
|
||||
|
||||
# Pattern: namespace:resource:id
|
||||
myapp:session:abc123
|
||||
myapp:ratelimit:user:123
|
||||
|
||||
# Pattern: resource:date (time-bound keys)
|
||||
stats:pageviews:2024-01-01
|
||||
```
|
||||
|
||||
### TTL Strategy
|
||||
|
||||
| Data Type | Suggested TTL |
|
||||
|-----------|--------------|
|
||||
| User session | 24h (`86400`) |
|
||||
| API response cache | 5–15 min |
|
||||
| Rate limit window | Match window size |
|
||||
| Short-lived tokens | 5–10 min |
|
||||
| Leaderboard | 1h–24h |
|
||||
| Static/reference data | 1h–1 week |
|
||||
|
||||
Always set a TTL. Keys without TTL accumulate indefinitely and cause memory pressure.
|
||||
|
||||
## Connection Management
|
||||
|
||||
### Connection Pooling
|
||||
|
||||
```python
|
||||
from redis import ConnectionPool, Redis
|
||||
|
||||
pool = ConnectionPool(
|
||||
host='localhost',
|
||||
port=6379,
|
||||
db=0,
|
||||
max_connections=20,
|
||||
decode_responses=True,
|
||||
socket_connect_timeout=2,
|
||||
socket_timeout=2,
|
||||
)
|
||||
|
||||
r = Redis(connection_pool=pool)
|
||||
```
|
||||
|
||||
### Cluster Mode
|
||||
|
||||
```python
|
||||
from redis.cluster import RedisCluster
|
||||
|
||||
r = RedisCluster(
|
||||
startup_nodes=[{"host": "redis-1", "port": 6379}],
|
||||
decode_responses=True,
|
||||
skip_full_coverage_check=True,
|
||||
)
|
||||
```
|
||||
|
||||
### Sentinel (High Availability)
|
||||
|
||||
```python
|
||||
from redis.sentinel import Sentinel
|
||||
|
||||
sentinel = Sentinel(
|
||||
[('sentinel-1', 26379), ('sentinel-2', 26379)],
|
||||
socket_timeout=0.5,
|
||||
)
|
||||
master = sentinel.master_for('mymaster', decode_responses=True)
|
||||
replica = sentinel.slave_for('mymaster', decode_responses=True)
|
||||
```
|
||||
|
||||
## Eviction Policies
|
||||
|
||||
| Policy | Behavior | Best For |
|
||||
|--------|----------|----------|
|
||||
| `noeviction` | Error on write when full | Queues / critical data |
|
||||
| `allkeys-lru` | Evict least recently used | General cache |
|
||||
| `volatile-lru` | LRU only among keys with TTL | Mixed data store |
|
||||
| `allkeys-lfu` | Evict least frequently used | Skewed access patterns |
|
||||
| `volatile-ttl` | Evict soonest-to-expire | Prioritize long-lived data |
|
||||
|
||||
Set via `redis.conf`: `maxmemory-policy allkeys-lru`
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Anti-Pattern | Problem | Fix |
|
||||
|---|---|---|
|
||||
| Keys with no TTL | Memory grows unbounded | Always set TTL |
|
||||
| `KEYS *` in production | Blocks the server (O(N)) | Use `SCAN` cursor |
|
||||
| Storing large blobs (>100KB) | Slow serialization, memory pressure | Store reference + fetch from object store |
|
||||
| Single Redis for everything | No isolation between cache & queue | Use separate DBs or instances |
|
||||
| Ignoring connection pool limits | Connection exhaustion under load | Size pool to workload |
|
||||
| Not handling cache miss stampede | Thundering herd on cold start | Use locks or probabilistic early expiry |
|
||||
| `FLUSHALL` without thought | Wipes entire instance | Scope deletes by key pattern |
|
||||
|
||||
### Cache Miss Stampede Prevention
|
||||
|
||||
```python
|
||||
import threading
|
||||
|
||||
_locks: dict[str, threading.Lock] = {}
|
||||
_locks_mutex = threading.Lock()
|
||||
|
||||
def get_with_lock(key: str, fetch_fn, ttl: int = 300):
|
||||
cached = r.get(key)
|
||||
if cached:
|
||||
return json.loads(cached)
|
||||
|
||||
with _locks_mutex:
|
||||
if key not in _locks:
|
||||
_locks[key] = threading.Lock()
|
||||
lock = _locks[key]
|
||||
with lock:
|
||||
cached = r.get(key) # Re-check after acquiring lock
|
||||
if cached:
|
||||
return json.loads(cached)
|
||||
value = fetch_fn()
|
||||
r.setex(key, ttl, json.dumps(value))
|
||||
return value
|
||||
```
|
||||
|
||||
> Note: for multi-process deployments, replace the in-process lock with `acquire_lock`/`release_lock` from the Distributed Locks section above.
|
||||
|
||||
## Examples
|
||||
|
||||
**Add caching to a Django/Flask API endpoint:**
|
||||
Use cache-aside with `setex` and a 5-minute TTL on the response. Key on the request parameters.
|
||||
|
||||
**Rate-limit an API by user:**
|
||||
Use fixed-window with `pipeline(transaction=True)` for low-traffic endpoints; use sliding-window Lua for accurate per-user throttling.
|
||||
|
||||
**Coordinate a background job across workers:**
|
||||
Use `acquire_lock` with a TTL that exceeds the expected job duration. Always release in a `finally` block.
|
||||
|
||||
**Fan-out notifications to multiple subscribers:**
|
||||
Use Pub/Sub for fire-and-forget. Switch to Streams if you need guaranteed delivery or replay for late consumers.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Pattern | When to Use |
|
||||
|---------|-------------|
|
||||
| Cache-aside | Read-heavy, tolerate slight staleness |
|
||||
| Write-through | Strong consistency required |
|
||||
| Distributed lock | Prevent concurrent access to a resource |
|
||||
| Sliding window rate limit | Accurate per-user throttling |
|
||||
| Redis Streams | Durable event queue with consumer groups |
|
||||
| Pub/Sub | Broadcast with no delivery guarantees needed |
|
||||
| Sorted Set leaderboard | Ranked scoring, pagination |
|
||||
| HyperLogLog | Approximate unique count at low memory |
|
||||
|
||||
## Related
|
||||
|
||||
- Skill: `postgres-patterns` — relational data patterns
|
||||
- Skill: `backend-patterns` — API and service layer patterns
|
||||
- Skill: `database-migrations` — schema versioning
|
||||
- Skill: `django-patterns` — Django cache framework integration
|
||||
- Agent: `database-reviewer` — full database review workflow
|
||||
@@ -20,6 +20,10 @@ Use this skill when:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 0. TOOL AVAILABILITY PREFLIGHT │
|
||||
│ Check search channels before relying on │
|
||||
│ them; report skipped channels honestly │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ 1. NEED ANALYSIS │
|
||||
│ Define what functionality is needed │
|
||||
│ Identify language/framework constraints │
|
||||
@@ -57,6 +61,19 @@ Use this skill when:
|
||||
|
||||
## How to Use
|
||||
|
||||
### Step 0: Tool Availability Preflight
|
||||
|
||||
This is agent guidance, not an executable setup script. Check only the channels
|
||||
that are relevant to the task and project in front of you.
|
||||
|
||||
| Channel | Check | If missing |
|
||||
|---------|-------|------------|
|
||||
| Repository search | `rg --files` and targeted `rg` queries | State that only visible files were inspected |
|
||||
| Package registry | `npm --version`, `python -m pip --version`, or project package manager | Use web/docs search and avoid claiming registry coverage |
|
||||
| GitHub CLI | `gh auth status` | Use public web or local git history only |
|
||||
| MCP/docs tools | Available tool list or local MCP config | Fall back to official docs/web search |
|
||||
| Skills directory | `ls ~/.claude/skills ~/.codex/skills` where applicable | Say no local skill catalog was available |
|
||||
|
||||
### Quick Mode (inline)
|
||||
|
||||
Before writing a utility or adding functionality, mentally run through:
|
||||
@@ -72,7 +89,7 @@ Before writing a utility or adding functionality, mentally run through:
|
||||
For non-trivial functionality, launch the researcher agent:
|
||||
|
||||
```
|
||||
Task(subagent_type="general-purpose", prompt="
|
||||
Agent(subagent_type="general-purpose", prompt="
|
||||
Research existing tools for: [DESCRIPTION]
|
||||
Language/framework: [LANG]
|
||||
Constraints: [ANY]
|
||||
@@ -82,6 +99,9 @@ Task(subagent_type="general-purpose", prompt="
|
||||
")
|
||||
```
|
||||
|
||||
Older Claude Code docs may call this `Task(...)`; use the current agent/subagent
|
||||
tool name exposed by the active harness.
|
||||
|
||||
## Search Shortcuts by Category
|
||||
|
||||
### Development Tooling
|
||||
@@ -96,7 +116,7 @@ Task(subagent_type="general-purpose", prompt="
|
||||
- Document processing → `unstructured`, `pdfplumber`, `mammoth`
|
||||
|
||||
### Data & APIs
|
||||
- HTTP clients → `httpx` (Python), `ky`/`got` (Node)
|
||||
- HTTP clients → `httpx` (Python), `ky`/`undici` (Node)
|
||||
- Validation → `zod` (TS), `pydantic` (Python)
|
||||
- Database → Check for MCP servers first
|
||||
|
||||
@@ -157,5 +177,6 @@ Result: 1 package + 1 schema file, no custom validation logic
|
||||
|
||||
- **Jumping to code**: Writing a utility without checking if one exists
|
||||
- **Ignoring MCP**: Not checking if an MCP server already provides the capability
|
||||
- **Silent skipping**: Reporting "nothing found" when a search channel was unavailable
|
||||
- **Over-customizing**: Wrapping a library so heavily it loses its benefits
|
||||
- **Dependency bloat**: Installing a massive package for one small feature
|
||||
|
||||
@@ -208,6 +208,11 @@ function renderUserContent(html: string) {
|
||||
```
|
||||
|
||||
#### Content Security Policy
|
||||
|
||||
Start strict and loosen only with a documented removal plan. Do not default to
|
||||
`'unsafe-inline'` or `'unsafe-eval'`; they neutralize much of CSP's protection
|
||||
and should be treated as temporary compatibility debt.
|
||||
|
||||
```typescript
|
||||
// next.config.js
|
||||
const securityHeaders = [
|
||||
@@ -215,8 +220,11 @@ const securityHeaders = [
|
||||
key: 'Content-Security-Policy',
|
||||
value: `
|
||||
default-src 'self';
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline';
|
||||
style-src 'self' 'unsafe-inline';
|
||||
base-uri 'self';
|
||||
object-src 'none';
|
||||
frame-ancestors 'none';
|
||||
script-src 'self';
|
||||
style-src 'self';
|
||||
img-src 'self' data: https:;
|
||||
font-src 'self';
|
||||
connect-src 'self' https://api.example.com;
|
||||
|
||||
@@ -15,6 +15,10 @@ from scripts.scenario_generator import Scenario
|
||||
|
||||
SANDBOX_BASE = Path("/tmp/skill-comply-sandbox")
|
||||
ALLOWED_MODELS = frozenset({"haiku", "sonnet", "opus"})
|
||||
# Shell builtins cannot be invoked via subprocess.run; cwd is already
|
||||
# controlled by the cwd= keyword. Scenarios that include these in
|
||||
# setup_commands (a common shell-style convention) must be tolerated.
|
||||
SHELL_BUILTINS = frozenset({"cd", "pushd", "popd"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -53,9 +57,22 @@ def run_scenario(
|
||||
cwd=sandbox_dir,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
# claude -p returns rc=1 when --max-turns is reached, but the stream-json
|
||||
# output is still complete and parseable. Treat this graceful termination
|
||||
# as non-fatal so scenarios that hit the turn cap still produce usable
|
||||
# observations.
|
||||
nonfatal_max_turns = (
|
||||
result.returncode == 1
|
||||
and '"terminal_reason":"max_turns"' in result.stdout
|
||||
)
|
||||
if result.returncode != 0 and not nonfatal_max_turns:
|
||||
# Include both stderr and stdout tails. claude -p often surfaces the
|
||||
# actual failure context (model error JSON, partial stream-json) on
|
||||
# stdout, while stderr carries generic transport / auth messages.
|
||||
# Showing both dramatically reduces "rc=N: <empty>" debugging dead-ends.
|
||||
raise RuntimeError(
|
||||
f"claude -p failed (rc={result.returncode}): {result.stderr[:500]}"
|
||||
f"claude -p failed (rc={result.returncode}): "
|
||||
f"stderr={result.stderr[:500]!r} stdout_tail={result.stdout[-500:]!r}"
|
||||
)
|
||||
|
||||
observations = _parse_stream_json(result.stdout)
|
||||
@@ -86,7 +103,15 @@ def _setup_sandbox(sandbox_dir: Path, scenario: Scenario) -> None:
|
||||
|
||||
for cmd in scenario.setup_commands:
|
||||
parts = shlex.split(cmd)
|
||||
subprocess.run(parts, cwd=sandbox_dir, capture_output=True)
|
||||
if not parts or parts[0] in SHELL_BUILTINS:
|
||||
# Shell builtins (cd/pushd/popd) cannot run as subprocess; skip.
|
||||
continue
|
||||
try:
|
||||
subprocess.run(parts, cwd=sandbox_dir, capture_output=True)
|
||||
except FileNotFoundError:
|
||||
# Setup tool not installed in this environment; skip rather than
|
||||
# crash the whole scenario. The compliance run continues.
|
||||
continue
|
||||
|
||||
|
||||
def _parse_stream_json(stdout: str) -> list[ObservationEvent]:
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Tests for runner module — scenario execution + subprocess error handling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.runner import _setup_sandbox, run_scenario
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _FakeScenario:
|
||||
"""Minimal Scenario-like object for runner tests (avoids generator deps)."""
|
||||
|
||||
id: str
|
||||
prompt: str = "do nothing"
|
||||
setup_commands: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class TestSetupSandboxSkipsShellBuiltins:
|
||||
"""Setup commands containing shell builtins (cd/pushd/popd) must be skipped.
|
||||
|
||||
Regression: subprocess.run(["cd", ...]) raises FileNotFoundError because
|
||||
cd is a shell builtin, not an external binary. Real-world scenarios often
|
||||
include "cd subdir" in setup_commands assuming shell semantics, so the
|
||||
runner must tolerate this rather than crashing the whole scenario.
|
||||
"""
|
||||
|
||||
def test_skips_cd(self, tmp_path):
|
||||
scenario = _FakeScenario(
|
||||
id="t1",
|
||||
setup_commands=("cd subdir",),
|
||||
)
|
||||
called_args: list[list[str]] = []
|
||||
|
||||
def fake_run(args, **kwargs):
|
||||
called_args.append(args)
|
||||
return subprocess.CompletedProcess(args=args, returncode=0)
|
||||
|
||||
with patch("scripts.runner.subprocess.run", side_effect=fake_run):
|
||||
_setup_sandbox(tmp_path, scenario)
|
||||
|
||||
# git init runs once; "cd subdir" must NOT be passed to subprocess
|
||||
assert ["git", "init"] in called_args
|
||||
assert ["cd", "subdir"] not in called_args
|
||||
|
||||
def test_skips_pushd_popd(self, tmp_path):
|
||||
scenario = _FakeScenario(
|
||||
id="t2",
|
||||
setup_commands=("pushd dir", "popd"),
|
||||
)
|
||||
called_args: list[list[str]] = []
|
||||
|
||||
def fake_run(args, **kwargs):
|
||||
called_args.append(args)
|
||||
return subprocess.CompletedProcess(args=args, returncode=0)
|
||||
|
||||
with patch("scripts.runner.subprocess.run", side_effect=fake_run):
|
||||
_setup_sandbox(tmp_path, scenario)
|
||||
|
||||
assert ["pushd", "dir"] not in called_args
|
||||
assert ["popd"] not in called_args
|
||||
|
||||
def test_tolerates_missing_executable(self, tmp_path):
|
||||
"""A scenario referencing an unavailable tool must not crash setup."""
|
||||
scenario = _FakeScenario(
|
||||
id="t3",
|
||||
setup_commands=("nonexistent-tool-xyz arg",),
|
||||
)
|
||||
|
||||
def fake_run(args, **kwargs):
|
||||
if args[0] == "nonexistent-tool-xyz":
|
||||
raise FileNotFoundError(2, "No such file or directory")
|
||||
return subprocess.CompletedProcess(args=args, returncode=0)
|
||||
|
||||
with patch("scripts.runner.subprocess.run", side_effect=fake_run):
|
||||
# Must NOT raise — missing tools are skipped, not fatal
|
||||
_setup_sandbox(tmp_path, scenario)
|
||||
|
||||
def test_real_commands_still_run(self, tmp_path):
|
||||
"""Skip logic must not break legitimate setup commands."""
|
||||
scenario = _FakeScenario(
|
||||
id="t4",
|
||||
setup_commands=("touch file.txt", "cd ignored", "echo hi"),
|
||||
)
|
||||
called_args: list[list[str]] = []
|
||||
|
||||
def fake_run(args, **kwargs):
|
||||
called_args.append(args)
|
||||
return subprocess.CompletedProcess(args=args, returncode=0)
|
||||
|
||||
with patch("scripts.runner.subprocess.run", side_effect=fake_run):
|
||||
_setup_sandbox(tmp_path, scenario)
|
||||
|
||||
# Real commands present, cd absent
|
||||
assert ["touch", "file.txt"] in called_args
|
||||
assert ["echo", "hi"] in called_args
|
||||
assert ["cd", "ignored"] not in called_args
|
||||
|
||||
|
||||
class TestRunScenarioMaxTurnsTermination:
|
||||
"""rc=1 with terminal_reason=max_turns is graceful termination, not failure.
|
||||
|
||||
claude -p returns rc=1 when --max-turns is reached, but the stream-json
|
||||
output is still valid. Treating this as RuntimeError aborts scenarios
|
||||
that would have produced useful observations. Detect the marker in stdout
|
||||
and downgrade rc=1 + max_turns to non-fatal.
|
||||
"""
|
||||
|
||||
def test_rc1_with_max_turns_marker_returns_normally(self, tmp_path, monkeypatch):
|
||||
scenario = _FakeScenario(id="mt1", prompt="long task", setup_commands=())
|
||||
|
||||
# Skip sandbox setup side effects
|
||||
monkeypatch.setattr("scripts.runner._setup_sandbox", lambda *a, **kw: None)
|
||||
|
||||
max_turns_stdout = (
|
||||
'{"type":"system","subtype":"init","session_id":"s1"}\n'
|
||||
'{"type":"result","terminal_reason":"max_turns"}\n'
|
||||
)
|
||||
|
||||
fake_result = subprocess.CompletedProcess(
|
||||
args=["claude"], returncode=1, stdout=max_turns_stdout, stderr=""
|
||||
)
|
||||
|
||||
with patch("scripts.runner.subprocess.run", return_value=fake_result):
|
||||
# Must NOT raise — max_turns is graceful termination
|
||||
run_scenario(scenario, model="haiku")
|
||||
|
||||
def test_rc1_without_max_turns_marker_still_raises(self, tmp_path, monkeypatch):
|
||||
"""Real failures (rc≠0 with no max_turns marker) must still raise."""
|
||||
scenario = _FakeScenario(id="mt2", prompt="oops", setup_commands=())
|
||||
monkeypatch.setattr("scripts.runner._setup_sandbox", lambda *a, **kw: None)
|
||||
|
||||
fake_result = subprocess.CompletedProcess(
|
||||
args=["claude"], returncode=1, stdout="", stderr="auth error"
|
||||
)
|
||||
|
||||
with patch("scripts.runner.subprocess.run", return_value=fake_result):
|
||||
with pytest.raises(RuntimeError, match="claude -p failed"):
|
||||
run_scenario(scenario, model="haiku")
|
||||
|
||||
|
||||
class TestRunScenarioErrorIncludesStdoutTail:
|
||||
"""Error messages must include stdout tail, not only stderr.
|
||||
|
||||
When claude -p fails inside an LLM call, useful diagnostic context often
|
||||
appears in stdout (partial stream-json events, model error JSON), not
|
||||
stderr. Including stdout tail in the RuntimeError message dramatically
|
||||
improves debug-ability without adding any new dependency.
|
||||
"""
|
||||
|
||||
def test_error_message_contains_stdout_tail(self, tmp_path, monkeypatch):
|
||||
scenario = _FakeScenario(id="e1", prompt="x", setup_commands=())
|
||||
monkeypatch.setattr("scripts.runner._setup_sandbox", lambda *a, **kw: None)
|
||||
|
||||
diagnostic_marker = "DIAG_STDOUT_MARKER_xyz123"
|
||||
fake_result = subprocess.CompletedProcess(
|
||||
args=["claude"],
|
||||
returncode=2,
|
||||
stdout=f"some context {diagnostic_marker} more text",
|
||||
stderr="generic error",
|
||||
)
|
||||
|
||||
with patch("scripts.runner.subprocess.run", return_value=fake_result):
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
run_scenario(scenario, model="haiku")
|
||||
|
||||
# Stdout marker MUST appear in the error message
|
||||
assert diagnostic_marker in str(excinfo.value)
|
||||
@@ -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`
|
||||
@@ -0,0 +1,134 @@
|
||||
---
|
||||
name: ui-to-vue
|
||||
description: Use when the user has UI screenshots or design exports that need batch conversion into Vue 3 components, especially with Vant, Element Plus, or Ant Design Vue.
|
||||
origin: community
|
||||
---
|
||||
|
||||
# UI To Vue
|
||||
|
||||
Batch-convert UI design screenshots into Vue 3 Composition API component code.
|
||||
|
||||
## When to Use
|
||||
|
||||
- The user provides a directory of design screenshots or design-export images.
|
||||
- The target application is Vue 3.
|
||||
- The user wants a first pass of page components, shared components, and router wiring.
|
||||
- The user specifies Vant, Element Plus, or Ant Design Vue as the component library.
|
||||
|
||||
## When Not to Use
|
||||
|
||||
- The user has only one screenshot and wants a bespoke component.
|
||||
- The target project is not Vue.
|
||||
- The design requires detailed interaction logic, data flow, or accessibility review.
|
||||
- The screenshots contain private customer data that cannot be sent to an external model API.
|
||||
|
||||
## Inputs
|
||||
|
||||
Use an input directory that groups screenshots by module and page state:
|
||||
|
||||
```text
|
||||
screenshots/
|
||||
|-- HomePage/
|
||||
| |-- List/
|
||||
| | |-- HomePage-List-Default@3x.png
|
||||
| | `-- cut-images/
|
||||
| |-- cut-images/
|
||||
| `-- HomePage-Default@3x.png
|
||||
`-- cut-images/
|
||||
```
|
||||
|
||||
Supported cut-image directory names include `assets`, `icons`, `sprites`, `cut`, `images`, and `cut-images`.
|
||||
|
||||
## Conversion Model
|
||||
|
||||
- Page grouping: combine related screenshots into one page component when they represent list, detail, form, loading, or empty states.
|
||||
- UI library mapping: map native visual elements to Vant, Element Plus, or Ant Design Vue components where practical.
|
||||
- Cut-image priority: prefer page-level assets, then module-level assets, then global shared assets.
|
||||
- Component extraction: extract repeated UI regions into shared components when they appear more than once.
|
||||
|
||||
## CLI Usage
|
||||
|
||||
Run the converter with `npx` so the documented command works without relying on a global binary:
|
||||
|
||||
```bash
|
||||
export DASHSCOPE_API_KEY=your_key
|
||||
npx ui-to-vue-converter@1.0.2 --input ./screenshots --ui vant --output ./src
|
||||
```
|
||||
|
||||
For desktop UI libraries:
|
||||
|
||||
```bash
|
||||
npx ui-to-vue-converter@1.0.2 --input ./designs --ui element-plus --output ./src
|
||||
npx ui-to-vue-converter@1.0.2 --input ./designs --ui antd-vue --output ./src
|
||||
```
|
||||
|
||||
If the package is installed globally, the `ui-to-vue` binary can be used directly:
|
||||
|
||||
```bash
|
||||
npm install -g ui-to-vue-converter@1.0.2
|
||||
ui-to-vue --input ./screenshots --ui vant --output ./src
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Description | Default |
|
||||
| --- | --- | --- |
|
||||
| `--input` | Design image directory | `./screenshots` |
|
||||
| `--ui` | UI library: `vant`, `element-plus`, or `antd-vue` | `vant` |
|
||||
| `--output` | Output directory | `./src` |
|
||||
| `--config` | Config file path | `./.ui-to-vue.config.json` |
|
||||
|
||||
## API Key Handling
|
||||
|
||||
The converter can read DashScope credentials from a config file or from the environment. Prefer an environment variable in repositories:
|
||||
|
||||
```bash
|
||||
export DASHSCOPE_API_KEY=your_key
|
||||
```
|
||||
|
||||
If a local config file is required, keep it out of version control:
|
||||
|
||||
```json
|
||||
{
|
||||
"apiKey": "your_dashscope_key",
|
||||
"input": "./designs",
|
||||
"ui": "vant",
|
||||
"output": "./src"
|
||||
}
|
||||
```
|
||||
|
||||
```gitignore
|
||||
.ui-to-vue.config.json
|
||||
```
|
||||
|
||||
## Security and Privacy
|
||||
|
||||
- Treat design screenshots as source material that may be sent to an external model API.
|
||||
- Do not run this flow on private customer designs without permission.
|
||||
- Pin the converter version in repeatable workflows instead of using `@latest`.
|
||||
- Review generated Vue code before committing it.
|
||||
- Do not commit `.ui-to-vue.config.json`, API keys, generated secrets, or customer screenshots.
|
||||
|
||||
## Output Review Checklist
|
||||
|
||||
- [ ] Page components were generated under `views/` or the chosen output directory.
|
||||
- [ ] Repeated UI regions were extracted into `components/` only when reuse is clear.
|
||||
- [ ] Router output is compatible with the target project's router style.
|
||||
- [ ] Generated components use the requested UI library consistently.
|
||||
- [ ] Generated CSS units match the design baseline.
|
||||
- [ ] The code passes the project's formatter, linter, type checker, and build.
|
||||
- [ ] Placeholder copy, mock data, and generated assets were reviewed before commit.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Check |
|
||||
| --- | --- |
|
||||
| `401` or authentication error | Confirm `DASHSCOPE_API_KEY` is set in the shell running the command. |
|
||||
| `command not found: ui-to-vue` | Use the `npx ui-to-vue-converter@1.0.2` form or install the package globally. |
|
||||
| Cut images are ignored | Confirm the asset directory name is supported and nested under the matching page or module. |
|
||||
| Components ignore the requested UI library | Re-run with an explicit `--ui` value and inspect the generated imports. |
|
||||
| Generated layout dimensions look wrong | Confirm the screenshot export width matches the target library baseline. |
|
||||
|
||||
## References
|
||||
|
||||
- npm package: `ui-to-vue-converter`
|
||||
@@ -0,0 +1,449 @@
|
||||
---
|
||||
name: vite-patterns
|
||||
description: Vite build tool patterns including config, plugins, HMR, env variables, proxy setup, SSR, library mode, dependency pre-bundling, and build optimization. Activate when working with vite.config.ts, Vite plugins, or Vite-based projects.
|
||||
origin: ECC
|
||||
---
|
||||
|
||||
# Vite Patterns
|
||||
|
||||
Build tool and dev server patterns for Vite 8+ projects. Covers configuration, environment variables, proxy setup, library mode, dependency pre-bundling, and common production pitfalls.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Configuring `vite.config.ts` or `vite.config.js`
|
||||
- Setting up environment variables or `.env` files
|
||||
- Configuring dev server proxy for API backends
|
||||
- Optimizing build output (chunks, minification, assets)
|
||||
- Publishing libraries with `build.lib`
|
||||
- Troubleshooting dependency pre-bundling or CJS/ESM interop
|
||||
- Debugging HMR, dev server, or build errors
|
||||
- Choosing or ordering Vite plugins
|
||||
|
||||
## How It Works
|
||||
|
||||
- **Dev mode** serves source files as native ESM — no bundling. Transforms happen on-demand per module request, which is why cold starts are fast and HMR is precise.
|
||||
- **Build mode** uses Rolldown (v7+) or Rollup (v5–v6) to bundle the app for production with tree-shaking, code-splitting, and Oxc-based minification.
|
||||
- **Dependency pre-bundling** converts CJS/UMD deps to ESM once via esbuild and caches the result under `node_modules/.vite`, so subsequent starts skip the work.
|
||||
- **Plugins** share a unified interface across dev and build — the same plugin object works for both the dev server's on-demand transforms and the production pipeline.
|
||||
- **Environment variables** are statically inlined at build time. `VITE_`-prefixed vars become public constants in the bundle; everything unprefixed is invisible to client code.
|
||||
|
||||
## Examples
|
||||
|
||||
### Config Structure
|
||||
|
||||
#### Basic Config
|
||||
|
||||
```typescript
|
||||
// vite.config.ts
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: { '@': new URL('./src', import.meta.url).pathname },
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
#### Conditional Config
|
||||
|
||||
```typescript
|
||||
// vite.config.ts
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig(({ command, mode }) => {
|
||||
const env = loadEnv(mode, process.cwd()) // VITE_ prefixed only (safe)
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
server: command === 'serve' ? { port: 3000 } : undefined,
|
||||
define: {
|
||||
__API_URL__: JSON.stringify(env.VITE_API_URL),
|
||||
},
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
#### Key Config Options
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `root` | `'.'` | Project root (where `index.html` lives) |
|
||||
| `base` | `'/'` | Public base path for deployed assets |
|
||||
| `envPrefix` | `'VITE_'` | Prefix for client-exposed env vars |
|
||||
| `build.outDir` | `'dist'` | Output directory |
|
||||
| `build.minify` | `'oxc'` | Minifier (`'oxc'`, `'terser'`, or `false`) |
|
||||
| `build.sourcemap` | `false` | `true`, `'inline'`, or `'hidden'` |
|
||||
|
||||
### Plugins
|
||||
|
||||
#### Essential Plugins
|
||||
|
||||
Most plugin needs are covered by a handful of well-maintained packages. Reach for these before writing your own.
|
||||
|
||||
| Plugin | Purpose | When to use |
|
||||
|--------|---------|-------------|
|
||||
| `@vitejs/plugin-react-swc` | React HMR + Fast Refresh via SWC | Default for React apps (faster than Babel variant) |
|
||||
| `@vitejs/plugin-react` | React HMR + Fast Refresh via Babel | Only if you need Babel plugins (emotion, MobX decorators) |
|
||||
| `@vitejs/plugin-vue` | Vue 3 SFC support | Vue apps |
|
||||
| `vite-plugin-checker` | Runs `tsc` + ESLint in worker thread with HMR overlay | **Any TypeScript app** — Vite does NOT type-check during `vite build` |
|
||||
| `vite-tsconfig-paths` | Honors `tsconfig.json` `paths` aliases | Any time you already have aliases in `tsconfig.json` |
|
||||
| `vite-plugin-dts` | Emits `.d.ts` files in library mode | Publishing TypeScript libraries |
|
||||
| `vite-plugin-svgr` | Imports SVGs as React components | React apps using SVGs as components |
|
||||
| `rollup-plugin-visualizer` | Bundle treemap/sunburst report | Periodic bundle size audits (use `enforce: 'post'`) |
|
||||
| `vite-plugin-pwa` | Zero-config PWA + Workbox | Offline-capable apps |
|
||||
|
||||
**Critical callout:** `vite build` transpiles but does NOT type-check. Type errors silently ship to production unless you add `vite-plugin-checker` or run `tsc --noEmit` in CI.
|
||||
|
||||
#### Authoring Custom Plugins
|
||||
|
||||
Authoring is rare — most needs are covered by existing plugins. When you do need one, start inline in `vite.config.ts` and only extract if reused.
|
||||
|
||||
```typescript
|
||||
// vite.config.ts — minimal inline plugin
|
||||
function myPlugin(): Plugin {
|
||||
return {
|
||||
name: 'my-plugin', // required, must be unique
|
||||
enforce: 'pre', // 'pre' | 'post' (optional)
|
||||
apply: 'build', // 'build' | 'serve' (optional)
|
||||
transform(code, id) {
|
||||
if (!id.endsWith('.custom')) return
|
||||
return { code: transformCustom(code), map: null }
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key hooks:** `transform` (modify source), `resolveId` + `load` (virtual modules), `transformIndexHtml` (inject into HTML), `configureServer` (add dev middleware), `hotUpdate` (custom HMR — replaces deprecated `handleHotUpdate` in v7+).
|
||||
|
||||
**Virtual modules** use the `\0` prefix convention — `resolveId` returns `'\0virtual:my-id'` so other plugins skip it. User code imports `'virtual:my-id'`.
|
||||
|
||||
For full plugin API, see [vite.dev/guide/api-plugin](https://vite.dev/guide/api-plugin). Use `vite-plugin-inspect` during development to debug the transform pipeline.
|
||||
|
||||
### HMR API
|
||||
|
||||
Framework plugins (`@vitejs/plugin-react`, `@vitejs/plugin-vue`, etc.) handle HMR automatically. Reach for `import.meta.hot` directly only when building custom state stores, dev tools, or framework-agnostic utilities that need to persist state across updates.
|
||||
|
||||
```typescript
|
||||
// src/store.ts — manual HMR for a vanilla module
|
||||
if (import.meta.hot) {
|
||||
// Persist state across updates (must MUTATE, never reassign .data)
|
||||
import.meta.hot.data.count = import.meta.hot.data.count ?? 0
|
||||
|
||||
// Cleanup side effects before module is replaced
|
||||
import.meta.hot.dispose((data) => clearInterval(data.intervalId))
|
||||
|
||||
// Accept this module's own updates
|
||||
import.meta.hot.accept()
|
||||
}
|
||||
```
|
||||
|
||||
All `import.meta.hot` code is tree-shaken out of production builds — no guard removal needed.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Vite loads `.env`, `.env.local`, `.env.[mode]`, and `.env.[mode].local` in that order (later overrides earlier); `*.local` files are gitignored and meant for local secrets.
|
||||
|
||||
#### Client-Side Access
|
||||
|
||||
Only `VITE_`-prefixed vars are exposed to client code:
|
||||
|
||||
```typescript
|
||||
import.meta.env.VITE_API_URL // string
|
||||
import.meta.env.MODE // 'development' | 'production' | custom
|
||||
import.meta.env.BASE_URL // base config value
|
||||
import.meta.env.DEV // boolean
|
||||
import.meta.env.PROD // boolean
|
||||
import.meta.env.SSR // boolean
|
||||
```
|
||||
|
||||
#### Using Env in Config
|
||||
|
||||
```typescript
|
||||
// vite.config.ts
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd()) // VITE_ prefixed only (safe)
|
||||
return {
|
||||
define: {
|
||||
__API_URL__: JSON.stringify(env.VITE_API_URL),
|
||||
},
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Security
|
||||
|
||||
#### `VITE_` Prefix is NOT a Security Boundary
|
||||
|
||||
Any variable prefixed with `VITE_` is **statically inlined into the client bundle at build time**. Minification, base64 encoding, and disabling source maps do NOT hide it. A determined attacker can extract any `VITE_` var from the shipped JavaScript.
|
||||
|
||||
**Rule:** Only public values (API URLs, feature flags, public keys) go in `VITE_` vars. Secrets (API tokens, database URLs, private keys) MUST live server-side behind an API or serverless function.
|
||||
|
||||
#### The `loadEnv('')` Trap
|
||||
|
||||
```typescript
|
||||
// BAD: passing '' as the third arg loads ALL env vars — including server secrets —
|
||||
// and makes them available to inline into client code via `define`.
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
|
||||
// GOOD: explicit prefix list
|
||||
const env = loadEnv(mode, process.cwd(), ['VITE_', 'APP_'])
|
||||
```
|
||||
|
||||
#### Source Maps in Production
|
||||
|
||||
Production source maps leak your original source code. Disable them unless you upload to an error tracker (Sentry, Bugsnag) and delete locally afterward:
|
||||
|
||||
```typescript
|
||||
build: {
|
||||
sourcemap: false, // default — keep it this way
|
||||
}
|
||||
```
|
||||
|
||||
#### `.gitignore` Checklist
|
||||
|
||||
- `.env.local`, `.env.*.local` — local secret overrides
|
||||
- `dist/` — build output
|
||||
- `node_modules/.vite` — pre-bundle cache (stale entries cause phantom errors)
|
||||
|
||||
### Server Proxy
|
||||
|
||||
```typescript
|
||||
// vite.config.ts — server.proxy
|
||||
server: {
|
||||
proxy: {
|
||||
'/foo': 'http://localhost:4567', // string shorthand
|
||||
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true, // needed for virtual-hosted backends
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
For WebSocket proxying, add `ws: true` to the route config.
|
||||
|
||||
### Build Optimization
|
||||
|
||||
#### Manual Chunks
|
||||
|
||||
```typescript
|
||||
// vite.config.ts — build.rolldownOptions
|
||||
build: {
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
// Object form: group specific packages
|
||||
manualChunks: {
|
||||
'react-vendor': ['react', 'react-dom'],
|
||||
'ui-vendor': ['@radix-ui/react-dialog', '@radix-ui/react-popover'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Function form: split by heuristic
|
||||
manualChunks(id) {
|
||||
if (id.includes('node_modules/react')) return 'react-vendor'
|
||||
if (id.includes('node_modules')) return 'vendor'
|
||||
}
|
||||
```
|
||||
|
||||
### Performance
|
||||
|
||||
#### Avoid Barrel Files
|
||||
|
||||
Barrel files (`index.ts` re-exporting everything from a directory) force Vite to load every re-exported file even when you import a single symbol. This is the #1 dev-server slowdown flagged by the official docs.
|
||||
|
||||
```typescript
|
||||
// BAD — importing one util forces Vite to load the whole barrel
|
||||
import { slash } from '@/utils'
|
||||
|
||||
// GOOD — direct import, only the one file is loaded
|
||||
import { slash } from '@/utils/slash'
|
||||
```
|
||||
|
||||
#### Be Explicit with Import Extensions
|
||||
|
||||
Each implicit extension forces up to 6 filesystem checks via `resolve.extensions`. In large codebases, this adds up.
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
import Component from './Component'
|
||||
|
||||
// GOOD
|
||||
import Component from './Component.tsx'
|
||||
```
|
||||
|
||||
Narrow `tsconfig.json` `allowImportingTsExtensions` + `resolve.extensions` to only the extensions you actually use.
|
||||
|
||||
#### Warm-Up Hot-Path Routes
|
||||
|
||||
`server.warmup.clientFiles` pre-transforms known hot entries before the browser requests them — eliminating the cold-load request waterfall on large apps.
|
||||
|
||||
```typescript
|
||||
// vite.config.ts
|
||||
server: {
|
||||
warmup: {
|
||||
clientFiles: ['./src/main.tsx', './src/routes/**/*.tsx'],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### Profiling Slow Dev Servers
|
||||
|
||||
When `vite dev` feels slow, start with `vite --profile`, interact with the app, then press `p+enter` to save a `.cpuprofile`. Load it in [Speedscope](https://www.speedscope.app) to find which plugins are eating time — usually `buildStart`, `config`, or `configResolved` hooks in community plugins.
|
||||
|
||||
### Library Mode
|
||||
|
||||
When publishing an npm package, use `build.lib`. Two footguns matter more than config detail:
|
||||
|
||||
1. **Types are not emitted** — add `vite-plugin-dts` or run `tsc --emitDeclarationOnly` separately.
|
||||
2. **Peer dependencies MUST be externalized** — unlisted peers get bundled into your library, causing duplicate-runtime errors in consumers.
|
||||
|
||||
```typescript
|
||||
// vite.config.ts
|
||||
build: {
|
||||
lib: {
|
||||
entry: 'src/index.ts',
|
||||
formats: ['es', 'cjs'],
|
||||
fileName: (format) => `my-lib.${format}.js`,
|
||||
},
|
||||
rolldownOptions: {
|
||||
external: ['react', 'react-dom', 'react/jsx-runtime'], // every peer dep
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### SSR Externals
|
||||
|
||||
Bare `createServer({ middlewareMode: true })` setups are framework-author territory. Most apps should use Nuxt, Remix, SvelteKit, Astro, or TanStack Start instead. What you *will* tweak as a framework user is the externals config when deps break in SSR:
|
||||
|
||||
```typescript
|
||||
// vite.config.ts — ssr options
|
||||
ssr: {
|
||||
external: ['node-native-package'], // keep as require() in SSR bundle
|
||||
noExternal: ['esm-only-package'], // force-bundle into SSR output (fixes most SSR errors)
|
||||
target: 'node', // 'node' or 'webworker'
|
||||
}
|
||||
```
|
||||
|
||||
### Dependency Pre-Bundling
|
||||
|
||||
Vite pre-bundles dependencies to convert CJS/UMD to ESM and reduce request count.
|
||||
|
||||
```typescript
|
||||
// vite.config.ts — optimizeDeps
|
||||
optimizeDeps: {
|
||||
include: [
|
||||
'lodash-es', // force pre-bundle known heavy deps
|
||||
'cjs-package', // CJS deps that cause interop issues
|
||||
'deep-lib/components/**', // glob for deep imports
|
||||
],
|
||||
exclude: ['local-esm-package'], // must be valid ESM if excluded
|
||||
force: true, // ignore cache, re-optimize (temporary debugging)
|
||||
}
|
||||
```
|
||||
|
||||
### Common Pitfalls
|
||||
|
||||
#### Dev Does Not Match Build
|
||||
|
||||
Dev uses esbuild/Rolldown for transforms; build uses Rolldown for bundling. CJS libraries can behave differently between the two. Always verify with `vite build && vite preview` before deploying.
|
||||
|
||||
#### Stale Chunks After Deployment
|
||||
|
||||
New builds produce new chunk hashes. Users with active sessions request old filenames that no longer exist. Vite has no built-in solution. Mitigations:
|
||||
|
||||
- Keep old `dist/assets/` files live for a deployment window
|
||||
- Catch dynamic import errors in your router and force a page reload
|
||||
|
||||
#### Docker and Containers
|
||||
|
||||
Vite binds to `localhost` by default, which is unreachable from outside a container:
|
||||
|
||||
```typescript
|
||||
// vite.config.ts — Docker/container setup
|
||||
server: {
|
||||
host: true, // bind 0.0.0.0
|
||||
hmr: { clientPort: 3000 }, // if behind a reverse proxy
|
||||
}
|
||||
```
|
||||
|
||||
#### Monorepo File Access
|
||||
|
||||
Vite restricts file serving to the project root. Packages outside root are blocked:
|
||||
|
||||
```typescript
|
||||
// vite.config.ts — monorepo file access
|
||||
server: {
|
||||
fs: {
|
||||
allow: ['..'], // allow parent directory (workspace root)
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Anti-Patterns
|
||||
|
||||
```typescript
|
||||
// BAD: Setting envPrefix to '' exposes ALL env vars (including secrets) to the client
|
||||
envPrefix: ''
|
||||
|
||||
// BAD: Assuming require() works in application source code — Vite is ESM-first
|
||||
const lib = require('some-lib') // use import instead
|
||||
|
||||
// BAD: Splitting every node_module into its own chunk — creates hundreds of tiny files
|
||||
manualChunks(id) {
|
||||
if (id.includes('node_modules')) {
|
||||
return id.split('node_modules/')[1].split('/')[0] // one chunk per package
|
||||
}
|
||||
}
|
||||
|
||||
// BAD: Not externalizing peer deps in library mode — causes duplicate runtime errors
|
||||
// build.lib without rolldownOptions.external
|
||||
|
||||
// BAD: Using deprecated esbuild minifier
|
||||
build: { minify: 'esbuild' } // use 'oxc' (default) or 'terser'
|
||||
|
||||
// BAD: Mutating import.meta.hot.data by reassignment
|
||||
import.meta.hot.data = { count: 0 } // WRONG: must mutate properties, not reassign
|
||||
import.meta.hot.data.count = 0 // CORRECT
|
||||
```
|
||||
|
||||
**Process anti-patterns:**
|
||||
|
||||
- **`vite preview` is NOT a production server** — it is a smoke test for the built bundle. Deploy `dist/` to a real static host (NGINX, Cloudflare Pages, Vercel static) or use a multi-stage Dockerfile.
|
||||
- **Expecting `vite build` to type-check** — it only transpiles. Type errors silently ship to production. Add `vite-plugin-checker` or run `tsc --noEmit` in CI.
|
||||
- **Shipping `@vitejs/plugin-legacy` by default** — it bloats bundles ~40%, breaks source-map bundle analyzers, and is unnecessary for the 95%+ of users on modern browsers. Gate it on real analytics, not assumption.
|
||||
- **Hand-rolling 30+ `resolve.alias` entries that duplicate `tsconfig.json` paths** — use `vite-tsconfig-paths` instead. Observed in Excalidraw and PostHog; avoid in new projects.
|
||||
- **Leaving stale `node_modules/.vite` after dep changes** — pre-bundle cache causes phantom errors. Clear it when switching branches or after patching deps.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Pattern | When to Use |
|
||||
|---------|-------------|
|
||||
| `defineConfig` | Always — provides type inference |
|
||||
| `loadEnv(mode, root, ['VITE_'])` | Access env vars in config (explicit prefix) |
|
||||
| `vite-plugin-checker` | Any TypeScript app (fills the type-check gap) |
|
||||
| `vite-tsconfig-paths` | Instead of hand-rolled `resolve.alias` |
|
||||
| `optimizeDeps.include` | CJS deps causing interop issues |
|
||||
| `server.proxy` | Route API requests to backend in dev |
|
||||
| `server.host: true` | Docker, containers, remote access |
|
||||
| `server.warmup.clientFiles` | Pre-transform hot-path routes |
|
||||
| `build.lib` + `external` | Publishing npm packages |
|
||||
| `manualChunks` (object) | Vendor bundle splitting |
|
||||
| `vite --profile` | Debug slow dev server |
|
||||
| `vite build && vite preview` | Smoke-test prod bundle locally (NOT a prod server) |
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `frontend-patterns` — React component patterns
|
||||
- `docker-patterns` — containerized dev with Vite
|
||||
- `nextjs-turbopack` — alternative bundler for Next.js
|
||||
@@ -6,6 +6,10 @@ origin: ECC
|
||||
|
||||
# X API
|
||||
|
||||
> **Drift-prone skill.** X API endpoints, access tiers, quotas, and write
|
||||
> permissions change frequently. Verify current developer docs and account
|
||||
> access before quoting rate limits or implementing a posting/search flow.
|
||||
|
||||
Programmatic interaction with X (Twitter) for posting, reading, searching, and analytics.
|
||||
|
||||
## When to Activate
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
"""Prompt module for prompt building and normalization."""
|
||||
|
||||
from llm.prompt.builder import PromptBuilder, adapt_messages_for_provider, get_provider_builder
|
||||
from llm.prompt.templates import TEMPLATES, get_template, get_template_or_default
|
||||
from llm.prompt.templates import (
|
||||
TEMPLATES,
|
||||
clear_templates,
|
||||
deregister_template,
|
||||
get_template,
|
||||
get_template_or_default,
|
||||
register_template,
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
"PromptBuilder",
|
||||
"TEMPLATES",
|
||||
"clear_templates",
|
||||
"deregister_template",
|
||||
"adapt_messages_for_provider",
|
||||
"get_provider_builder",
|
||||
"get_template",
|
||||
"get_template_or_default",
|
||||
"register_template",
|
||||
)
|
||||
|
||||
@@ -19,9 +19,32 @@ class PromptConfig:
|
||||
tool_format: str = "native"
|
||||
|
||||
|
||||
class PromptBuilder:
|
||||
def __init__(self, config: PromptConfig | None = None) -> None:
|
||||
self.config = config or PromptConfig()
|
||||
class PromptBuilder:
|
||||
def __init__(
|
||||
self,
|
||||
config: PromptConfig | None = None,
|
||||
*,
|
||||
system_template: str | None = None,
|
||||
user_template: str | None = None,
|
||||
include_tools_in_system: bool | None = None,
|
||||
tool_format: str | None = None,
|
||||
) -> None:
|
||||
if config is not None and any(
|
||||
value is not None
|
||||
for value in (system_template, user_template, include_tools_in_system, tool_format)
|
||||
):
|
||||
raise ValueError("Pass either config or PromptBuilder keyword options, not both")
|
||||
|
||||
if config is None:
|
||||
overrides = {
|
||||
"system_template": system_template,
|
||||
"user_template": user_template,
|
||||
"include_tools_in_system": include_tools_in_system,
|
||||
"tool_format": tool_format,
|
||||
}
|
||||
config = PromptConfig(**{key: value for key, value in overrides.items() if value is not None})
|
||||
|
||||
self.config = config
|
||||
|
||||
def build(self, messages: list[Message], tools: list[ToolDefinition] | None = None) -> list[Message]:
|
||||
if not messages:
|
||||
|
||||
@@ -1 +1,41 @@
|
||||
# Templates module for provider-specific prompt templates
|
||||
"""Provider-specific prompt template helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
_TEMPLATE_REGISTRY: dict[str, str] = {}
|
||||
TEMPLATES = _TEMPLATE_REGISTRY
|
||||
|
||||
|
||||
def _validate_template_input(name: str, template: str | None = None) -> None:
|
||||
"""Validate template registry inputs before mutating the registry."""
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
raise ValueError("Template name must be a non-empty string")
|
||||
if template is not None and (not isinstance(template, str) or not template.strip()):
|
||||
raise ValueError("Template content must be a non-empty string")
|
||||
|
||||
|
||||
def register_template(name: str, template: str) -> None:
|
||||
"""Register or replace a named prompt template."""
|
||||
_validate_template_input(name, template)
|
||||
_TEMPLATE_REGISTRY[name] = template
|
||||
|
||||
|
||||
def deregister_template(name: str) -> None:
|
||||
"""Remove a named prompt template if it is registered."""
|
||||
_validate_template_input(name)
|
||||
_TEMPLATE_REGISTRY.pop(name, None)
|
||||
|
||||
|
||||
def clear_templates() -> None:
|
||||
"""Remove all registered prompt templates."""
|
||||
_TEMPLATE_REGISTRY.clear()
|
||||
|
||||
|
||||
def get_template(name: str) -> str | None:
|
||||
"""Return a named prompt template when one is registered."""
|
||||
return _TEMPLATE_REGISTRY.get(name)
|
||||
|
||||
|
||||
def get_template_or_default(name: str, default: str = "") -> str:
|
||||
"""Return a named prompt template or a caller-provided default."""
|
||||
return _TEMPLATE_REGISTRY.get(name, default)
|
||||
|
||||
+30
-18
@@ -57,27 +57,39 @@ class ClaudeProvider(LLMProvider):
|
||||
}
|
||||
if input.max_tokens:
|
||||
params["max_tokens"] = input.max_tokens
|
||||
else:
|
||||
params["max_tokens"] = 8192 # required by Anthropic API
|
||||
if input.tools:
|
||||
params["tools"] = [tool.to_dict() for tool in input.tools]
|
||||
else:
|
||||
params["max_tokens"] = 8192 # required by Anthropic API
|
||||
if input.tools:
|
||||
params["tools"] = [tool.to_anthropic_tool() for tool in input.tools]
|
||||
|
||||
response = self.client.messages.create(**params)
|
||||
|
||||
tool_calls = None
|
||||
if response.content and hasattr(response.content[0], "type"):
|
||||
if response.content[0].type == "tool_use":
|
||||
tool_calls = [
|
||||
ToolCall(
|
||||
id=getattr(response.content[0], "id", ""),
|
||||
name=getattr(response.content[0], "name", ""),
|
||||
arguments=getattr(response.content[0].input, "__dict__", {}),
|
||||
)
|
||||
]
|
||||
|
||||
return LLMOutput(
|
||||
content=response.content[0].text if response.content else "",
|
||||
tool_calls=tool_calls,
|
||||
text_parts: list[str] = []
|
||||
tool_calls: list[ToolCall] = []
|
||||
for block in response.content or []:
|
||||
block_type = getattr(block, "type", None)
|
||||
if block_type == "text":
|
||||
text = getattr(block, "text", "")
|
||||
if text:
|
||||
text_parts.append(text)
|
||||
elif block_type == "tool_use":
|
||||
raw_arguments = getattr(block, "input", {})
|
||||
arguments = (
|
||||
raw_arguments.copy()
|
||||
if isinstance(raw_arguments, dict)
|
||||
else getattr(raw_arguments, "__dict__", {}).copy()
|
||||
)
|
||||
tool_calls.append(
|
||||
ToolCall(
|
||||
id=getattr(block, "id", ""),
|
||||
name=getattr(block, "name", ""),
|
||||
arguments=arguments,
|
||||
)
|
||||
)
|
||||
|
||||
return LLMOutput(
|
||||
content="".join(text_parts),
|
||||
tool_calls=tool_calls or None,
|
||||
model=response.model,
|
||||
usage={
|
||||
"input_tokens": response.usage.input_tokens,
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user