Compare commits

...

24 Commits

Author SHA1 Message Date
Affaan Mustafa 1c06ad9524 docs: salvage ui-to-vue skill 2026-05-11 06:07:46 -04:00
Affaan Mustafa b39d2244cf docs: salvage focused stale PR contributions
- add Vite and Redis pattern skills from closed stale PRs

- add frontend-slides support assets

- port skill-comply runner fixes and LLM prompt/provider regressions

- harden agent frontmatter validation and sync catalog counts
2026-05-11 05:31:12 -04:00
Affaan Mustafa d8f879e671 docs: salvage focused skill curation updates (#1723)
Port the safe, narrow pieces from contributor PR #1694 without taking the broad 11-skill rewrite.

- add drift-prone warnings to external research/media/API skills

- make search-first verify tool availability and use current agent naming

- remove unsafe in-memory rate limiter example from backend patterns

- tighten the CSP example in security-review

Validation: node scripts/ci/validate-skills.js --strict; npx markdownlint targeted skill files; node tests/ci/validators.test.js && node tests/ci/catalog.test.js; npm run lint; node tests/run-all.js
2026-05-11 05:03:34 -04:00
Affaan Mustafa d352270b9a docs: port Russian README translation (#1722)
* docs: add Russian README translation

* docs: update README language label

* docs: sync Russian README catalog counts

---------

Co-authored-by: Nikita <nkovalenko1@icloud.com>
2026-05-11 04:44:12 -04:00
Affaan Mustafa 6fd20ffc72 feat: port Swift language agents (#1721) 2026-05-11 04:27:59 -04:00
Affaan Mustafa 7fa1e5b6db fix: port LLM provider config and tool schemas 2026-05-11 04:12:35 -04:00
Affaan Mustafa f442bac8c9 fix: port Windows hook safety fixes (#1719) 2026-05-11 03:56:51 -04:00
Affaan Mustafa 12e1bc424d fix: port continuous-learning observer fixes
Ports continuous-learning observer signal, storage, remote normalization, and v1 deprecation fixes onto current main.
2026-05-11 03:35:42 -04:00
Affaan Mustafa e674a7dbd7 fix: harden CI validators
Ports personal-path validator hardening and quoted checkout detection onto current main.
2026-05-11 03:08:43 -04:00
Affaan Mustafa 1abc3fb381 fix: port hook session and dashboard safety fixes
Ports suggest-compact session_id isolation and dashboard terminal/document launch safety onto current main.
2026-05-11 02:53:28 -04:00
Affaan Mustafa 27508842b1 fix: sync skill frontmatter and catalog counts
Adds missing skill frontmatter, normalizes strict YAML metadata, syncs README catalog counts, and extends catalog validation for README/plugin/marketplace count drift.
2026-05-11 02:33:29 -04:00
Affaan Mustafa 8a57679222 fix: restore short Claude plugin slug and skill installs (#1712) 2026-05-11 02:10:36 -04:00
Affaan Mustafa 7b964402ee fix: bypass GateGuard file gates in subagents (#1710) 2026-05-11 01:51:24 -04:00
Bill LeVine f8a0c4f884 feat(skills): add flox-environments skill (#1317)
* feat(skills): add flox-environments skill

Add a skill for creating reproducible, cross-platform development
environments with Flox. Covers manifest structure, package installation
patterns, language-specific recipes (Python, Node, Rust, Go, C/C++),
hooks/profile configuration, anti-patterns, environment sharing, and
AI-assisted/vibe coding workflows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(skills): address review feedback on flox-environments

- Add initdb guard to full-stack example so PostgreSQL works on first run
- Replace hardcoded /tmp path with mktemp in agent workflow snippet

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(skills): use variable for mktemp path in agent workflow

$_ resolves to the previous command's last argument (-c), not the
mktemp path. Use an explicit variable instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Update skills/flox-environments/SKILL.md

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-05-11 01:21:43 -04:00
Quang Tran 754bdbf440 feat: add ios-icon-gen skill (#1356)
* feat: add ios-icon-gen skill for Xcode asset catalog icon generation

Add a skill that generates PNG icon imagesets (1x, 2x, 3x) for Xcode
asset catalogs from two sources:

- Iconify API: 275k+ open source icons from 200+ collections
  (Material Design, Phosphor, Tabler, Lucide, etc.)
- SF Symbols: 5k+ Apple-native symbols (macOS only)

Includes search, preview, and generation scripts with customizable
size, color, weight, and direct output to asset catalogs.

* fix: address PR review feedback for ios-icon-gen skill

Security:
- Fix shell injection in iconify_gen.sh by passing query via sys.argv
  instead of interpolating into Python string literal

Robustness:
- Replace all try!/force-unwrap with do/try/catch and guard let in
  generate_icons.swift for graceful error handling
- Add option value validation (require_value/requireOptionValue) in
  both scripts to prevent crashes on missing flag values
- Add curl timeouts (--connect-timeout 10, --max-time 30) to all
  network calls
- Add sips conversion failure warnings instead of silent suppression
- Add error handling for curl in list_collections

Documentation:
- Rename SKILL.md sections to "When to Use", "How It Works", "Examples"
  to match repo conventions

* fix: restore canonical SKILL.md headers and validate color/weight CLI inputs

- Revert SKILL.md section headers back to "When to Activate" and
  "Core Principles" per CONTRIBUTING.md and SKILL-DEVELOPMENT-GUIDE.md
  (the prior rename to "When to Use"/"How It Works" was incorrect)
- Validate --color as a 6-digit hex code at parse time instead of
  silently falling back to the default gray
- Validate --weight against the known set of font weights instead of
  silently falling back to thin

---------

Co-authored-by: Quang Tran <16215255+trmquang93@users.noreply.github.com>
2026-05-11 01:19:47 -04:00
James M. ZHOU f01929c31a feat: add tinystruct-patterns skill and bootstrapping guidance (#1336)
* feat: add tinystruct-patterns skill and bootstrapping guidance

* docs(skills): restructuralize tinystruct-patterns to standard skill format

- Reorganize SKILL.md and all reference documents into "When to Use", "How It Works", and "Examples" sections to conform to project standards.
- Refine data-handling.md example to return Builder objects directly, leveraging framework auto-serialization.
- Simplify @Action examples in routing.md for better readability.
- Clarify framework mechanics including CLI bootstrapping via ApplicationManager.init(), event-driven architecture, and regex-based routing.

* Update skills/tinystruct-patterns/references/testing.md

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update skills/tinystruct-patterns/SKILL.md

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update skills/tinystruct-patterns/references/routing.md

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Update skills/tinystruct-patterns/references/testing.md

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Update skills/tinystruct-patterns/references/testing.md

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-05-11 01:19:44 -04:00
Gaurav Dubey e196f8a4cb fix(ci): flag SKILL.md frontmatter defects in validate-skills (#1669)
* fix(ci): flag SKILL.md frontmatter defects in validate-skills

Issue #1663 reported two SKILL.md frontmatter defects (missing `name:`
on skill-stocktake; literal block-scalar `description: |-` on
openclaw-persona-forge) that PR #1664 addresses at the data level.

This change is complementary: it extends `scripts/ci/validate-skills.js`
to catch the same class of defect statically going forward, so the
frontmatter-vs-renderer problems do not silently reappear as new skills
land.

## Checks added
- Frontmatter must declare a `name:` field.
- Frontmatter `description:` must not use a literal block scalar
  (`|` / `|-` / `|+`) — these preserve internal newlines and break
  flat-table renderers keyed off `description`. Folded (`>`) and inline
  strings are accepted.

## Behavior
- Frontmatter findings default to WARN (exit 0) so this PR does not
  break CI while the two known offenders are still on main. Pass
  `--strict` or set `CI_STRICT_SKILLS=1` to promote them to ERROR
  (exit 1). Structural findings (missing / empty SKILL.md) remain
  errors as before.
- Today against main, the validator reports exactly two warnings —
  the same two files called out in #1663 — and exits 0. When #1664
  lands, the validator reports zero warnings, at which point strict
  mode can be enabled in CI.

## Parser notes
- Bespoke frontmatter parser mirrors the style of `validate-agents.js`
  (tolerant of UTF-8 BOM and CRLF; no new npm dependency).
- Block-scalar continuation lines are skipped so keys inside a block
  scalar are not mistaken for top-level keys.
- Hidden directories (`.something/`) under skills/ are now skipped.

## Tests
Adds five focused tests to `tests/ci/validators.test.js`:
- warns when frontmatter is missing `name` (default mode)
- errors when frontmatter is missing `name` (--strict mode)
- warns on literal block-scalar description (|-)
- accepts folded (>) and inline descriptions under --strict
- skips hidden directories under skills/

## Docs
Adds two bullets to the `Skill Checklist` in CONTRIBUTING.md covering
the two rules now surfaced by the validator.

Refs #1663. Complements (does not compete with) #1664.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ci): harden SKILL.md frontmatter checks after bot review

Address findings from CodeRabbit, Greptile, and cubic on #1669:

- Guard empty or whitespace-only `name:` values. Previously
  `name:    ` silently passed because the presence check only
  tested key-set membership; now inspectFrontmatter captures
  trimmed values and validate flags an explicit 'name is empty'
  WARN/ERROR.
- Broaden block-scalar detection to cover YAML 1.2 indent
  indicators (`|2`, `|-2`, `>2-`) and trailing comments
  (`|-  # note`). The old regex required a bare `|`/`>` with
  optional `+`/`-`, which let valid-but-disallowed forms slip
  through.
- Update CONTRIBUTING.md checklist to list `|+` alongside `|`
  and `|-` for parity with the validator.
- Extend runSkillsValidator to accept env overrides and add four
  regression tests: empty name, |+ description, |-2 + comment, and
  CI_STRICT_SKILLS=1.

* fix(ci): address round-2 review on validate-skills frontmatter

- Tighten extractFrontmatter closing delimiter to require a newline or
  end-of-file after the closing `---`, so body lines beginning with
  `---text` are not parsed as frontmatter (CodeRabbit).
- Strip both trailing and comment-only values in inspectFrontmatter, so
  `name: # todo` is surfaced as empty rather than silently passing
  (cubic P2).
- Extract validateSkillDir helper so the per-directory validation
  block moves out of validateSkills, keeping both functions under the
  50-line guideline (CodeRabbit nit).
- Hoist runSkillsValidator to module scope in the test harness and
  share the spawnSync import with execFileSync so the helper stops
  re-requiring child_process on every invocation (CodeRabbit nit).
- Add regression tests: comment-only `name:` values must fail strict
  mode; `---trailing` body lines must not be parsed as frontmatter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Update tests/ci/validators.test.js

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-05-11 01:14:38 -04:00
Michael 600072ebd8 fix(hooks): resolve MCP health-check spawn ENOENT on Windows (#1456)
* fix(hooks): resolve MCP health-check spawn ENOENT on Windows

On Windows, commands like 'npx' are batch files (npx.cmd) that require
shell expansion to resolve via PATH. Without shell: true, Node.js
spawn() fails with ENOENT.

However, absolute paths (e.g. C:\Program Files\nodejs\node.exe) must
NOT use shell mode because cmd.exe misparses paths containing spaces.

Fix: enable shell mode only for non-absolute commands on Windows, using
path.isAbsolute() to distinguish. This matches how attemptReconnect()
already handles the shell option.

Fixes #1455

* fix(hooks): harden Windows shell spawn — validate command for metacharacters

Addresses bot review feedback on PR #1456:

- Add UNSAFE_SHELL_CHARS regex to guard against shell injection when
  needsShell=true: cmd.exe operators (&, |, <, >, ^, %, !, (), ;,
  whitespace) are rejected before shell mode is enabled
- Add typeof command === 'string' check so path.isAbsolute() cannot
  throw on malformed non-string command values
- Rename test to 'via PATH resolution' (not Windows-only; runs all platforms)
- Fix misleading test comment: 'node' resolves via PATH like npx.cmd but
  does not itself use .cmd; comment now accurately reflects the intent

* fix(hooks): kill full process tree on Windows when shell mode is used

When needsShell=true, the spawned child is cmd.exe. Calling child.kill()
only terminates the shell, leaving the real server process orphaned.

Use taskkill /PID <pid> /T /F on Windows+shell to kill the entire
process tree rooted at cmd.exe. Fall back to SIGTERM+SIGKILL on all
other platforms or when shell mode is not active.

* fix(hooks): fall back to child.kill() when taskkill fails

Windows taskkill can fail if it's not on PATH, the process already
exited, or permissions are denied. Previously the failure was silently
ignored and no kill signal reached the child.

Now: capture the spawnSync result and fall back to child.kill('SIGKILL')
on any taskkill error or non-zero status. This still may leak a
detached server process but at least guarantees the cmd.exe shell is
signaled.
2026-05-11 01:13:37 -04:00
Gaurav Dubey 2bb88cff47 docs(strategic-compact): fix hook command path in zh-CN/zh-TW/ja-JP SKILL.md (#1701)
Extends the hook command path correction from PR #1682 (English source) to
the zh-CN, zh-TW, and ja-JP translated mirrors so the PreToolUse hook
example matches the actual script location at
~/.claude/scripts/hooks/suggest-compact.js.

Changes per locale:

- docs/zh-CN/skills/strategic-compact/SKILL.md: update both command strings
  from ~/.claude/skills/strategic-compact/suggest-compact.js to
  ~/.claude/scripts/hooks/suggest-compact.js.

- docs/zh-TW/skills/strategic-compact/SKILL.md: replace the outdated
  suggest-compact.sh reference (the .sh variant was removed in merged PR
  #41) with the current node-invoked suggest-compact.js, and align the
  matcher block structure with the English canonical SKILL.md post-#1682.

- docs/ja-JP/skills/strategic-compact/SKILL.md: same .sh -> .js migration
  and matcher alignment as zh-TW.

The ko-KR mirror already uses the correct CLAUDE_PLUGIN_ROOT-based hook
path and needs no change.

Refs #1675
2026-05-11 01:13:12 -04:00
Gaurav Dubey 105b524c8f docs(strategic-compact): fix hook command path in SKILL.md (#1682)
The Hook Setup example pointed to
`~/.claude/skills/strategic-compact/suggest-compact.js`, which does not
exist in the current repo layout. The cross-platform Node.js hook ships
at `scripts/hooks/suggest-compact.js` and is installed to
`~/.claude/scripts/hooks/suggest-compact.js`.

Anyone copy-pasting the documented config hit a broken hook command.

Closes #1675

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:13:08 -04:00
dependabot[bot] 61a30a1f15 build(deps): bump the minor-and-patch group across 1 directory with 3 updates (#1582)
Bumps the minor-and-patch group with 3 updates in the / directory: [ajv](https://github.com/ajv-validator/ajv), @opencode-ai/plugin and [globals](https://github.com/sindresorhus/globals).


Updates `ajv` from 8.18.0 to 8.20.0
- [Release notes](https://github.com/ajv-validator/ajv/releases)
- [Commits](https://github.com/ajv-validator/ajv/compare/v8.18.0...v8.20.0)

Updates `@opencode-ai/plugin` from 1.3.15 to 1.14.33

Updates `globals` from 17.4.0 to 17.6.0
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v17.4.0...v17.6.0)

---
updated-dependencies:
- dependency-name: "@opencode-ai/plugin"
  dependency-version: 1.14.25
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: ajv
  dependency-version: 8.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: globals
  dependency-version: 17.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 01:12:35 -04:00
dependabot[bot] c013479019 build(deps): bump pnpm/action-setup from 6.0.0 to 6.0.6 (#1708)
Bumps [pnpm/action-setup](https://github.com/pnpm/action-setup) from 6.0.0 to 6.0.6.
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/08c4be7e2e672a47d11bd04269e27e5f3e8529cb...91ab88e2619ed1f46221f0ba42d1492c02baf788)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: 6.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 01:12:31 -04:00
dependabot[bot] baba4ec1ab build(deps): bump fast-uri from 3.1.0 to 3.1.2 (#1703)
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.0 to 3.1.2.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.2)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 01:11:54 -04:00
dependabot[bot] 01b171947c chore(deps): bump actions/cache from 5.0.4 to 5.0.5 (#1497)
Bumps [actions/cache](https://github.com/actions/cache) from 5.0.4 to 5.0.5.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/668228422ae6a00e4ad889ee87cd7109ec5666a7...27d5ce7f107fe9357f9df03efb73ab90386fccae)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 01:11:50 -04:00
122 changed files with 9184 additions and 459 deletions
+1
View File
@@ -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`:
+3 -3
View File
@@ -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",
+8 -4
View File
@@ -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/"
]
}
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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",
+9 -5
View File
@@ -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
+5 -5
View File
@@ -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') }}
+3
View File
@@ -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
+3 -3
View File
@@ -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)
+2
View File
@@ -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
+22 -22
View File
@@ -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
View File
@@ -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
}
}
```
-1
View File
@@ -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.
+161
View File
@@ -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`.
+107
View File
@@ -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?"
+5 -5
View File
@@ -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
}
}
```
+1 -1
View File
@@ -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" と言って起動します
---
+11 -8
View File
@@ -21,7 +21,7 @@ description: 任意の自動コンパクションではなく、タスクフェ
## 仕組み
`suggest-compact.sh`スクリプトはPreToolUseEdit/Write)で実行され:
`suggest-compact.js`スクリプトはPreToolUseEdit/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" }]
}
]
}
}
```
+9 -9
View File
@@ -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
```
플러그인에서 사용할 수 있는 모든 에이전트, 커맨드, 스킬을 보여줍니다.
+9 -9
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -125,7 +125,7 @@ Bu repository yalnızca ham kodu içerir. Rehberler her şeyi açıklıyor.
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
# Plugin'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.
+3 -3
View File
@@ -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
View File
@@ -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 条指令 |
+1 -1
View File
@@ -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
---
+2 -2
View File
@@ -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" }]
}
]
}
+5 -5
View File
@@ -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
}
}
```
+11 -8
View File
@@ -21,7 +21,7 @@ description: Suggests manual context compaction at logical intervals to preserve
## 運作方式
`suggest-compact.sh` 腳本在 PreToolUseEdit/Write)執行並:
`suggest-compact.js` 腳本在 PreToolUseEdit/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
View File
@@ -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"""
+2 -1
View File
@@ -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",
+1
View File
@@ -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
View File
@@ -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
+106
View File
@@ -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,
+15
View File
@@ -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}`);
+54 -10
View File
@@ -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
View File
@@ -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();
+1 -1
View File
@@ -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) {
+24
View File
@@ -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 || '';
+143 -64
View File
@@ -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);
});
}
+1 -1
View File
@@ -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');
+2 -3
View File
@@ -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' },
+19 -3
View File
@@ -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
+2
View File
@@ -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
+10
View File
@@ -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()) {
+10 -1
View File
@@ -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
+54 -2
View File
@@ -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);
+9
View File
@@ -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()) {
+40 -3
View File
@@ -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,
+7 -44
View File
@@ -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
+1 -1
View File
@@ -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"
---
+19 -5
View File
@@ -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
+10 -2
View File
@@ -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.
+4
View File
@@ -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
+4
View File
@@ -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
+4
View File
@@ -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
+496
View File
@@ -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 |
+419
View File
@@ -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/
```
+418
View File
@@ -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)")
+153
View File
@@ -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;
}
}
+157
View File
@@ -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
View File
@@ -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)
}
+235
View File
@@ -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 -9
View File
@@ -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
---
+403
View File
@@ -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 | 515 min |
| Rate limit window | Match window size |
| Short-lived tokens | 510 min |
| Leaderboard | 1h24h |
| Static/reference data | 1h1 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
+23 -2
View File
@@ -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
+10 -2
View File
@@ -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;
+28 -3
View File
@@ -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]:
+172
View File
@@ -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
View File
@@ -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
---
+2 -2
View File
@@ -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" }]
}
]
}
+131
View File
@@ -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`
+134
View File
@@ -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`
+449
View File
@@ -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 (v5v6) 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
+4
View File
@@ -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
+18
View File
@@ -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:
+11 -1
View File
@@ -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",
)
+26 -3
View File
@@ -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:
+41 -1
View File
@@ -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
View File
@@ -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,
+1 -1
View File
@@ -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]
+38 -2
View File
@@ -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:
+26
View File
@@ -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);
}
+211
View File
@@ -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