feat(install): add guided multi-harness installer (#2649)

* feat(install): add guided Claude plugin setup

* fix: support Claude command shims on Windows

* feat: support safe Claude plugin scope migration

* fix(install): preserve interactive setup terminal

* fix(install): auto-migrate setup scope changes

* feat(install): add guided multi-harness installer

* fix(install): sync Yarn binary metadata

* fix(install): handle wizard EOF on Node 18

* ci: allow installer matrix tests to finish

* test(install): allow slower PowerShell delegation

* fix(install): harden guided provider reconciliation

* test(install): harden packaged and local compatibility

* chore: prepare guided installer release 2.2.0

* fix(install): report refreshed Codex marketplace state

* fix(install): verify managed content provenance

* test(install): allow empty Yarn smoke fixture

* test(install): invoke Windows package shims safely

* fix(install): close cross-platform release gaps

* fix(install): require trusted GitHub origins

* fix(install): preserve hook profile precedence

* refactor(install): centralize trusted GitHub origins

* ci: retrigger workflow run after merge of main

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
haelyra
2026-08-06 15:39:49 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent 623f2c020f
commit 28e53a0bc1
91 changed files with 10958 additions and 1172 deletions
+2 -2
View File
@@ -6,10 +6,10 @@
"plugins": [
{
"name": "ecc",
"version": "2.1.0",
"version": "2.2.0",
"source": {
"source": "local",
"path": "./plugins/ecc"
"path": "./"
},
"policy": {
"installation": "AVAILABLE",
+1 -1
View File
@@ -12,7 +12,7 @@
"name": "ecc",
"source": "./",
"description": "Harness-native ECC operator layer - 67 agents, 281 skills, 94 legacy command shims, reusable hooks, rules, selective install profiles, and production-ready workflows for Claude Code, Codex, OpenCode, Cursor, and related agent harnesses",
"version": "2.1.0",
"version": "2.2.0",
"author": {
"name": "Affaan Mustafa",
"email": "me@affaanmustafa.com"
+15 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ecc",
"version": "2.1.0",
"version": "2.2.0",
"description": "Harness-native ECC plugin for engineering teams - 67 agents, 281 skills, 94 legacy command shims, reusable hooks, rules, MCP conventions, and operator workflows for Claude Code plus adjacent agent harnesses",
"author": {
"name": "Affaan Mustafa",
@@ -22,6 +22,20 @@
"automation",
"best-practices"
],
"userConfig": {
"hooks_enabled": {
"type": "boolean",
"title": "Enable ECC hooks",
"description": "Run ECC's local lifecycle, quality, and safety automation. Disable this to keep skills and commands without local hook automation.",
"default": true
},
"hook_profile": {
"type": "string",
"title": "ECC hook profile",
"description": "Choose minimal, standard, or strict. Invalid values safely fall back to standard.",
"default": "standard"
}
},
"mcpServers": {},
"skills": [
"./skills/"
+64 -32
View File
@@ -8,35 +8,83 @@ This directory contains the **Codex plugin manifest** for ECC.
.codex-plugin/
└── plugin.json — Codex plugin manifest (name, version, skills ref, MCP ref)
.mcp.json — MCP server configurations at plugin root (NOT inside .codex-plugin/)
hooks/codex-hooks.json — Codex-compatible lifecycle hook projection
```
## What This Provides
- **249 skills** from `./skills/` — reusable Codex workflows for TDD, security,
- **281 skills** from `./skills/` — reusable Codex workflows for TDD, security,
code review, architecture, and more
- **6 MCP servers** — GitHub, Context7, Exa, Memory, Playwright, Sequential Thinking
- **1 default MCP server** — Chrome DevTools; retired connectors remain opt-in
- **Codex lifecycle hooks** — synchronous command hooks on supported events,
with explicit review and trust in `/hooks`
## Installation
Codex plugin support is marketplace-backed. The repo exposes a repo-scoped
marketplace at `.agents/plugins/marketplace.json`; Codex can add and track that
marketplace source from the CLI:
Codex 0.146.0 and newer use `plugin add`, not `plugin install`. Add ECC's
repository marketplace, install the native plugin, and verify the registration:
```bash
# Add the public repo marketplace
codex plugin marketplace add affaan-m/ECC
# Or add a local checkout while developing
codex plugin marketplace add /absolute/path/to/ECC
codex plugin add ecc@ecc
codex plugin list --json
```
The marketplace entry points at `plugins/ecc/` — Codex does not discover
plugins whose local marketplace `source.path` is the marketplace root (`./`),
so the entry must target a concrete plugin subdirectory (see
[#2128](https://github.com/affaan-m/ECC/issues/2128)). That thin plugin folder
references the root `skills/` and `.mcp.json` so content stays single-sourced.
After adding or updating the marketplace, restart Codex and install or enable
`ecc` from the plugin directory.
Both add commands are safe to run again. A repeated marketplace add reports
`alreadyAdded: true`, and a repeated plugin add keeps the same enabled plugin
registration. To fetch a newer marketplace snapshot before applying a new ECC
release, run:
```bash
codex plugin marketplace upgrade ecc
codex plugin add ecc@ecc
```
For local development, the same native journey accepts a checkout path:
```bash
codex plugin marketplace add /absolute/path/to/ECC
codex plugin add ecc@ecc
```
ECC's marketplace entry points at the repository root. Codex copies the selected
plugin source into its cache, so the root source keeps `skills/`, `.mcp.json`,
`hooks/`, hook scripts, and presentation assets together. Parent-relative paths
from a thin plugin directory would escape that cache and produce an installed
registration with missing runtime content.
Restart Codex after installation. You can also open `/plugins` in Codex CLI to
inspect, enable, disable, or remove the plugin. The native Codex plugin does not
use Claude's `user`, `project`, or `local` install scopes: its enabled state is
stored once in the active `CODEX_HOME` (normally `~/.codex`) and applies to
Codex sessions using that home.
## Hooks and reconfiguration
The Codex manifest uses the documented `hooks` field to bundle
`./hooks/codex-hooks.json`. This provider-specific projection keeps the
synchronous `SessionStart` bootstrap verified against Codex 0.146. Claude hook
profiles are not Codex hook profiles: handlers that block tools, use unsupported
events, run asynchronously, or fail Codex's hook protocol stay out of the native
bundle. Codex enables hook support by default, but native plugin installation
does not silently authorize commands. Start a new Codex session, open `/hooks`,
then review and trust the ECC hook definition before enabling it.
Codex records trust against each definition's hash, so changed hooks require
review again. Use `/plugins` for plugin enablement and `/hooks` for hook trust;
these are separate controls.
Once the cached skills are available, invoke `$configure-ecc` inside Codex for
ECC's guided configuration. Installing the plugin again is idempotent and does
not create a second scope or duplicate hook registration.
## Native plugin versus legacy managed sync
The commands above are the native Codex plugin path. The legacy managed sync
(`bash scripts/sync-ecc-to-codex.sh`) is a separate compatibility
path that merges files into `~/.codex`. It is not a native plugin install and
does not create a marketplace registration. Prefer the native path on current
Codex; use the legacy managed sync only when you intentionally need its copied
configuration layer.
After install, `codex plugin list` is only a registration check. From an ECC
checkout, run the cache check to verify that the installed manifest can resolve
@@ -46,22 +94,6 @@ its referenced skills, MCP config, and assets:
node scripts/codex/check-plugin-cache.js
```
> **Plugin mode is currently fragile on Codex.** Marketplace discovery and
> install work with this layout, but runtime skill loading from local/repo
> marketplaces is unreliable upstream
> ([openai/codex#26037](https://github.com/openai/codex/issues/26037)) — Codex
> copies only the plugin folder into its install cache, so parent-referenced
> content may not be exposed in a fresh session. The safer, fully supported
> path today is the manual sync flow:
> `npm install && bash scripts/sync-ecc-to-codex.sh`.
Official Plugin Directory publishing is coming soon. For official OpenAI
plugin-directory review, package this repo under the `openai/plugins`
repository shape: `plugins/ecc/.codex-plugin/plugin.json`,
`plugins/ecc/skills/`, and the supporting README/assets. Until that listing is
accepted, treat the public repo marketplace as the supported Codex distribution
path and keep release copy framed as repo-marketplace/manual installation.
The installed plugin registers under the short slug `ecc` so tool and command names
stay below provider length limits.
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "ecc",
"version": "2.1.0",
"version": "2.2.0",
"description": "Harness-native ECC workflows for Codex: shared skills, production-ready MCP configs, and selective-install-aligned conventions for TDD, security scanning, code review, and autonomous development.",
"author": {
"name": "Affaan Mustafa",
@@ -13,9 +13,10 @@
"keywords": ["codex", "agents", "skills", "tdd", "code-review", "security", "workflow", "automation"],
"skills": "./skills/",
"mcpServers": "./.mcp.json",
"hooks": "./hooks/codex-hooks.json",
"interface": {
"displayName": "ECC",
"shortDescription": "249 ECC skills plus MCP configs for TDD, security, code review, and autonomous development.",
"shortDescription": "281 ECC skills plus MCP configs for TDD, security, code review, and autonomous development.",
"longDescription": "ECC is a harness-native operator system for Codex and adjacent agent harnesses. It packages reusable skills, MCP configs, TDD workflows, security scanning, code review, architecture decisions, operator workflows, and release gates in one installable plugin.",
"developerName": "Affaan Mustafa",
"category": "Coding",
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
test:
name: Test (${{ matrix.os }}, Node ${{ matrix.node }}, ${{ matrix.pm }})
runs-on: ${{ matrix.os }}
timeout-minutes: 10
timeout-minutes: 20
strategy:
fail-fast: false
+11 -8
View File
@@ -1,13 +1,15 @@
# ECC for Kimi Code CLI
This directory contains the ECC (Everything Claude Code) configuration for the Kimi Code CLI harness.
This directory documents ECC (Everything Claude Code) support for its tested Kimi Code CLI compatibility target. The managed adapter is verified against Kimi Code 0.31.x (`@moonshot-ai/kimi-code`); newer provider releases are outside this adapter's verified range.
## What Kimi Code discovers natively
- `AGENTS.md` — project instructions loaded by Kimi Code's hierarchical instruction discovery
- `skills/` — project skills loaded by Kimi Code's native Agent Skills discovery
- `.kimi-code/AGENTS.md` — project instructions loaded by Kimi Code's hierarchical instruction discovery
- `.kimi-code/skills/` — project skills loaded by Kimi Code's native Agent Skills discovery
- `.agents/skills/` — an additional project-level Agent Skills location supported by Kimi Code
- `.kimi-code/mcp.json` — project MCP server configuration
ECC also copies shared rules, agents, and legacy command shims into `.kimi/` for portability and reference. Kimi Code's native invocation surface is Agent Skills (`/skill:<name>` and `/flow:<name>`), not arbitrary Markdown files in `commands/`.
ECC installs its directly discoverable skills under `.kimi-code/skills/` and keeps shared rules, agents, and legacy command shims under `.kimi-code/` for portability and reference. Kimi Code's native invocation surface is Agent Skills (`/skill:<name>` and `/flow:<name>`), not arbitrary Markdown files in `commands/`.
## Manual install
@@ -17,11 +19,12 @@ bash ./install.sh --target kimi --profile minimal
## Notes
- The `kimi` target installs into the project-level `./.kimi/` directory.
- Kimi Code CLI's own config (`~/.kimi-code/config.toml`, plugins) is **not** touched by ECC install.
- Use `npx ecc doctor --target kimi` to check install health.
- The `kimi` target installs into the project-level `./.kimi-code/` directory.
- Kimi Code CLI's user config (`~/.kimi-code/config.toml`) is **not** touched by the project installer.
- Use `npx ecc-universal doctor --target kimi` to check install health.
- The ECC adapter verified against Kimi Code 0.31.x does not configure or map provider lifecycle hooks. Provider hook availability is separate from this adapter's compatibility contract.
- Kimi Code provider configuration remains separate. Use the [official providers and models guide](https://moonshotai.github.io/kimi-cli/en/configuration/providers.html) for Kimi API, OpenAI-compatible, Anthropic, or other supported endpoints.
- Kimi Code's [Agent Skills guide](https://moonshotai.github.io/kimi-cli/en/customization/skills.html) documents the `.kimi/skills/` discovery contract.
- Kimi Code's [Agent Skills guide](https://moonshotai.github.io/kimi-cli/en/customization/skills.html) documents the current project discovery contract.
## Self-hosted model compute
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ecc-universal",
"version": "2.1.0",
"version": "2.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ecc-universal",
"version": "2.1.0",
"version": "2.2.0",
"license": "MIT",
"devDependencies": {
"@opencode-ai/plugin": "^1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ecc-universal",
"version": "2.1.0",
"version": "2.2.0",
"description": "ECC plugin for OpenCode - agents, commands, hooks, and skills",
"main": "dist/index.js",
"types": "dist/index.d.ts",
+1 -1
View File
@@ -537,7 +537,7 @@ export const ECCHooksPlugin: ECCHooksPluginFn = async ({
const contextBlock = [
"# ECC Context (preserve across compaction)",
"",
"## Active Plugin: ECC v2.1.0",
"## Active Plugin: ECC v2.2.0",
"- Hooks: file.edited, tool.execute.before/after, session.created/idle/deleted, shell.env, compacting, permission.ask",
"- Tools: run-tests, check-coverage, security-audit, format-code, lint-check, git-summary, changed-files",
"- Agents: 13 specialized (planner, architect, tdd-guide, code-reviewer, security-reviewer, build-error-resolver, e2e-runner, refactor-cleaner, doc-updater, go-reviewer, go-build-resolver, database-reviewer, python-reviewer)",
+1 -1
View File
@@ -2,7 +2,7 @@
This is a **production-ready AI coding plugin** providing 67 specialized agents, 281 skills, 94 commands, and automated hook workflows for software development.
**Version:** 2.1.0
**Version:** 2.2.0
## Core Principles
+89 -34
View File
@@ -129,30 +129,95 @@ Access to 67 agents, 281 skills, and 94 legacy command shims, plus hooks, rules,
## Install ECC
> [!NOTE]
> The guided commands below require `ecc-universal` 2.2.0 or newer. If npm
> still resolves 2.1.0, use the provider-native instructions below until the
> 2.2.0 package is published.
### Pick one path only (per harness)
You can use ECC with Claude Code, Codex, and other harnesses at the same time. Choose one install method for each harness:
- **Works:** Claude Code plugin + Codex sync
- **Recommended default:** run the guided Claude plugin setup with `npx ecc-universal setup`
- **Recommended for multiple harnesses:** run `npx ecc-universal install --guided`
- **Works:** Claude Code plugin + Codex native plugin
- **Works:** Claude Code plugin + the legacy Codex sync flow
- **Avoid:** Claude Code plugin + full Claude manual install
- **Avoid:** Codex sync + Codex marketplace plugin
**Recommended default:** install the Claude Code plugin for Claude Code and use the supported sync flow for Codex. **Do not stack install methods.** Installing ECC twice into the same harness can duplicate skills, commands, hooks, or configuration; installing it once into multiple harnesses does not.
**Do not stack install methods.** Installing ECC twice into the same harness can duplicate skills, commands, hooks, or configuration; installing it once into multiple harnesses does not.
If you already layered multiple installs and things look duplicated, skip straight to [Reset / Uninstall ECC](#reset--uninstall-ecc).
**Install trouble?** Open the short [install or runtime problem form](https://github.com/affaan-m/ECC/issues/new?template=install-problem.yml), or run `ecc feedback`. ECC never uploads diagnostics automatically.
### Guided setup (recommended)
For Claude Code plugin setup, updates, scope changes, and hook-profile changes:
```bash
npx ecc-universal setup
```
The same published package works with modern package runners:
| Package runner | Guided setup command |
|---|---|
| npm / npx | `npx ecc-universal setup` |
| pnpm | `pnpm dlx ecc-universal setup` |
| Yarn 2+ | `yarn dlx ecc-universal setup` |
| Bun | `bunx ecc-universal setup` |
Yarn Classic 1 does not provide `yarn dlx`; use `npx`, install the package globally, or upgrade Yarn for a temporary one-shot run.
The wizard inventories the official marketplace and every native Claude install scope before making changes, then installs, updates, or safely moves `ecc@ecc` to the scope you choose. Rerun the same command whenever you want to update ECC, change scope, or change its hook profile. This setup wizard currently configures the Claude Code plugin; use the multi-harness wizard below for Codex or Kimi Code.
To configure more than one coding agent in one reviewed flow, use the multi-harness wizard:
```bash
npx ecc-universal install --guided
```
It lets you select any combination of Claude Code, Codex, and Kimi Code, shows each install channel and destination, preflights every selection before the first write, and asks for one final confirmation.
| Harness | Guided install behavior |
|---|---|
| Claude Code | Native `ecc@ecc` plugin with one `user`, `project`, or `local` scope and an ECC hook profile |
| Codex | Native Codex marketplace/plugin lifecycle; hook review and trust remain Codex-owned |
| Kimi Code | Managed project files under `./.kimi-code`; ECC hooks, model/provider settings, and authentication are not configured |
For automation, make every provider-specific choice explicit:
```bash
npx ecc-universal install --guided \
--harness claude --harness codex --harness kimi \
--claude-scope local --claude-hooks standard \
--profile core --yes
```
Verify the native guided Codex path and managed Kimi path without writing first:
```bash
npx ecc-universal install --guided --harness codex --dry-run
npx ecc-universal install --profile core --target kimi --dry-run
```
ECC also ships advanced managed adapters for `cursor`, `antigravity`, `gemini`, `opencode`, `codebuddy`, `joycode`, `qwen`, `zed`, `hermes`, and `openclaw`. Those targets still use their documented `ecc install --target ...` paths until each adapter has passed the guided collision, update, repair, and uninstall lifecycle matrix. Neither wizard silently installs into every detected harness.
### Claude Code
Run these commands inside Claude Code:
Use Claude Code's built-in marketplace commands only when you specifically want the native path or cannot run the package wizard:
```text
/plugin marketplace add https://github.com/affaan-m/ECC
/plugin install ecc@ecc
```
That installs ECC's skills, agents, commands, and plugin-managed hooks. Claude Code plugins cannot distribute `rules`, so add only the rule packs you actually want:
That installs ECC's skills, agents, commands, and plugin-managed hooks. Claude Code owns these built-in commands, including their errors when a marketplace, plugin, or conflicting scope already exists. ECC cannot intercept that parser. If either command reports an existing install or scope conflict, run `npx ecc-universal setup`; the ECC-owned flow inspects the current state and chooses install, update, or verified scope migration instead of blindly adding a duplicate.
After ECC is installed, `/ecc:configure-ecc` is the namespaced in-Claude reconfiguration skill. It delegates to the same safe setup flow, but it is available only after the plugin is installed and cannot replace Claude Code's built-in `/plugin` command during a first install.
Claude Code plugins cannot distribute `rules`, so add only the rule packs you actually want:
```bash
git clone https://github.com/affaan-m/ECC.git
@@ -206,7 +271,18 @@ If your local Claude setup was wiped or reset, that does not mean you need to re
### Codex App and CLI
The reliable ECC setup for Codex is the sync flow. Run Codex once first so `~/.codex/config.toml` exists. The sync preserves your existing Codex files, creates timestamped backups, and merges ECC's `AGENTS.md`, skills, prompts, agents, and reference config into `~/.codex`:
Current Codex releases can install ECC as a native repo-marketplace plugin. The marketplace entry uses the repository root so Codex's cache receives the manifest together with all referenced skills, MCP configuration, hook runtime, scripts, and assets:
```bash
codex plugin marketplace add affaan-m/ECC
codex plugin add ecc@ecc
codex plugin list --json
node scripts/codex/check-plugin-cache.js
```
Both add commands are idempotent. To refresh later, run `codex plugin marketplace upgrade ecc` followed by `codex plugin add ecc@ecc`. Codex stores one enabled plugin state in the active `CODEX_HOME`; it does not offer Claude's `user`, `project`, and `local` scopes. Its native hooks require an explicit trust decision and do not use Claude's four ECC hook profiles. Inside Codex, invoke `$configure-ecc` for the guided provider-aware flow.
The older `scripts/sync-ecc-to-codex.sh` path remains a separate compatibility option for users who intentionally want copied and merged configuration in `~/.codex`; it is not required for the native plugin. Run Codex once first so `~/.codex/config.toml` exists, then:
```bash
git clone https://github.com/affaan-m/ECC.git
@@ -215,30 +291,9 @@ npm install
bash scripts/sync-ecc-to-codex.sh
```
You can also open the ECC repository directly in Codex for a project-local setup. Codex reads the root `AGENTS.md` and the trusted project configuration in `.codex/` without a global sync.
You can also open the ECC repository directly in Codex for a project-local setup. Codex reads the root `AGENTS.md` and the trusted project configuration in `.codex/` without a global sync. Do not add the native marketplace plugin on top of the sync flow.
For repo navigation, surface ownership, and PR diff packet guidance, read the [Codex ECC Navigation Map](docs/CODEX-NAVIGATION-GUIDE.md).
<details>
<summary><strong>Codex plugin marketplace (experimental for ECC)</strong></summary>
Codex officially supports plugin marketplaces, and ECC publishes a repo marketplace:
```bash
codex plugin marketplace add affaan-m/ECC
codex plugin marketplace list
```
Restart Codex, then install or enable `ecc` from the Plugins directory. Do not add the marketplace plugin on top of the Codex sync flow. Marketplace registration is stable in Codex, but ECC's current plugin package references shared repository content that may not be copied into Codex's install cache. Until that upstream cache behavior is resolved, use the sync flow above when you need all ECC skills reliably.
From an ECC checkout, verify the installed plugin cache with:
```bash
node scripts/codex/check-plugin-cache.js
```
See the [.codex plugin notes](.codex-plugin/README.md) for the current limitation and tracking issues.
</details>
For repo navigation, surface ownership, and PR diff packet guidance, read the [Codex ECC Navigation Map](docs/CODEX-NAVIGATION-GUIDE.md). See the [.codex plugin notes](.codex-plugin/README.md) for native lifecycle details.
### Other agents and editors
@@ -262,7 +317,7 @@ cd ECC
| Qwen CLI | `./install.sh --profile minimal --target qwen` | See the [Qwen guide](docs/QWEN-GUIDE.md) |
| Hermes | `./install.sh --profile minimal --target hermes` | See the [Hermes setup guide](docs/HERMES-SETUP.md) |
| OpenClaw | `./install.sh --profile minimal --target openclaw` | Managed home-directory install |
| Kimi Code CLI | `./install.sh --profile minimal --target kimi` | Project-local `.kimi/` install |
| Kimi Code CLI | `./install.sh --profile minimal --target kimi` | Project-local `.kimi-code/` install |
| CodeBuddy | `./install.sh --profile minimal --target codebuddy` | Project-local `.codebuddy/` install |
| JoyCode | `./install.sh --profile minimal --target joycode` | Project-local `.joycode/` install |
@@ -323,7 +378,7 @@ Add the hook runtime later only if you want it:
Ask the packaged advisor which components match your work:
```bash
npx ecc consult "security reviews" --target claude
npx ecc-universal consult "security reviews" --target claude
```
It returns matching components, related profiles, and preview/install commands. Use the preview command before installing if you want to inspect the exact file plan.
@@ -332,7 +387,7 @@ You can also install explicit skills or capabilities:
```bash
./install.sh --target claude --skills tdd-workflow,security-review
npx ecc install --profile minimal --target claude --with capability:machine-learning
npx ecc-universal install --profile minimal --target claude --with capability:machine-learning
```
Manual component-by-component copying also works. Each component is fully independent:
@@ -469,7 +524,7 @@ Run or self-host any open-source model behind that gateway using separate comput
### Self-host Kimi with ECC + Itô compute
The Kimi Code harness and the model-serving layer are separate. ECC configures the agent harness; you bring an API endpoint or self-host an open-weight Kimi model on your own GPU capacity:
The Kimi Code harness and the model-serving layer are separate. ECC configures the agent harness; you bring an API endpoint or self-host an open-weight Kimi model on your own GPU capacity. This adapter is verified against Kimi Code 0.31.x (`@moonshot-ai/kimi-code`):
<table aria-label="Local Kimi model path" width="100%">
<tr>
@@ -501,11 +556,11 @@ Configure the endpoint with Kimi Code's <a href="https://moonshotai.github.io/ki
```bash
bash ./install.sh --target kimi --profile minimal
npx ecc doctor --target kimi
npx ecc-universal doctor --target kimi
kimi
```
Kimi Code discovers the installed `.kimi/AGENTS.md` instructions and `.kimi/skills/` workflows natively. The installer dry-run and regression suite verify that the Kimi target stays inside the project-local `.kimi/` root.
Kimi Code discovers the installed `.kimi-code/AGENTS.md` instructions and `.kimi-code/skills/` workflows natively; project-level `.agents/skills/` is also an official discovery location. ECC safely merges project MCP entries into `.kimi-code/mcp.json` and does not change the user-level `~/.kimi-code/config.toml`. Kimi Code supports native hooks, but ECC's current managed-project adapter does not configure them, so this installer does not offer Kimi hook profiles. The installer dry-run and regression suite verify that every managed Kimi write stays inside the project-local `.kimi-code/` root.
### Itô compute CLI bridge
+4
View File
@@ -80,6 +80,10 @@
## 最新动态
### v2.2.0 — 引导式多 Harness 安装(2026年8月)
新增可审查的 Claude Code、Codex 与 Kimi Code 多 Harness 安装流程,并提供同步的 npm 命令入口。
### v2.1.0 — 智能体 Harness 操作系统(2026年6月)
2.0 主线稳定版:261 个技能、control-pane 基底(会话适配器 + MCP 清单)、worktree 生命周期服务,以及 [ECC Discord 社区](https://discord.gg/36yGMHGFbR)。
+1 -1
View File
@@ -1 +1 @@
2.1.0
2.2.0
+1 -1
View File
@@ -1,6 +1,6 @@
spec_version: "0.1.0"
name: ecc
version: 2.1.0
version: 2.2.0
description: "Initial gitagent export surface for ECC's shared skill catalog, governance, and identity. Native agents, commands, and hooks remain authoritative in the repository while manifest coverage expands."
author: affaan-m
license: MIT
+1 -1
View File
@@ -703,7 +703,7 @@ Suggested payload:
"skippedModules": []
},
"source": {
"repoVersion": "2.1.0",
"repoVersion": "2.2.0",
"repoCommit": "git-sha",
"manifestVersion": 1
},
+139 -267
View File
@@ -1,313 +1,185 @@
---
name: configure-ecc
description: Everything Claude Code のインタラクティブなインストーラー — スキルとルールの選択とインストールをユーザーレベルまたはプロジェクトレベルのディレクトリへガイドし、パスを検証し、必要に応じてインストールされたファイルを最適化します。
description: Claude Code、Codex、Kimi 内で ECC のインストール、更新、再設定を案内し、各ハーネスが実際に備えるプラグイン、スコープ、フック機能を守ります。
metadata:
origin: ECC
---
# Configure Everything Claude Code (ECC)
# Everything Claude Code の設定
Everything Claude Code プロジェクトのインタラクティブなステップバイステップのインストールウィザードです。`AskUserQuestion` を使用してスキルとルールの選択的インストールをユーザーにガイドし、正確性を検証し、最適化を提供します。
現在のハーネス内で対話式ウィザードを実行します。最初にインベントリを調べ、対応する選択肢だけを
収集し、プレビュー後に 1 回だけ確認し、非対話で適用・検証します。ウェルカム表示は成功後だけです。
ECC を一時ディレクトリへ clone したり、プラグインを手作業でコピーしたりしないでください。
## 起動タイミング
ユーザー自身が操作するターミナルの正規エントリは `ecc setup``npx ecc-universal setup` です。
ハーネス内では、代わりに以下の明示的な非対話コマンドを使います。
- ユーザーが "configure ecc"、"install ecc"、"setup everything claude code" などと言った場合
- ユーザーがこのプロジェクトからスキルまたはルールを選択的にインストールしたい場合
- ユーザーが既存の ECC インストールを検証または修正したい場合
- ユーザーがインストールされたスキルまたはルールをプロジェクト用に最適化したい場合
## 現在のハーネスで分岐
## 前提条件
- Claude Code では、以下の完全なスコープ/フックウィザードを使います。
- Codex では Codex ネイティブのプラグインライフサイクルを使います。Claude のスコープを提示したり、
Claude の ECC フック 4 段階を Codex に対応付けたりしません。
- Kimi ではプロジェクトサーフェスを `./.kimi-code` に導入します。Kimi は ECC の Claude ライフサイクル
フックプロファイルに対応しません。
- ハーネスを特定できない場合は、検出根拠を示し、変更コマンドの前に対象を質問します。
このスキルは起動前に Claude Code からアクセス可能である必要があります。ブートストラップには2つの方法があります:
1. **プラグイン経由**: `/plugin install ecc@ecc` — プラグインがこのスキルを自動的にロードします
2. **手動**: このスキルのみを `~/.claude/skills/configure-ecc/SKILL.md` にコピーし、"configure ecc" と言って起動します
このスキルは導入後の再設定経路です。プロバイダー組み込みの初回導入 UI を横取り、または代替できません。
---
## Claude Code: 完全な対話式ウィザード
## ステップ 0: ECC リポジトリのクローン
### 1. 変更せずにインベントリを確認
インストールの前に、最新の ECC ソースを `/tmp` にクローンします
両方のコマンドを実行し、ECC の導入スコープ、有効状態、marketplace ソースを要約します
```bash
rm -rf /tmp/everything-claude-code
git clone https://github.com/affaan-m/everything-claude-code.git /tmp/everything-claude-code
claude plugin list --json
claude plugin marketplace list --json
```
以降のすべてのコピー操作のソースとして `ECC_ROOT=/tmp/everything-claude-code` を設定します。
`ecc@ecc` が 1 つのみ既存する場合は再設定として扱います。Claude が所有する
"Open home page" コントロールをインストールの根拠にしません。setup が複数の ECC スコープ、
旧式/手動導入、不正な設定、marketplace 衝突を報告したら停止し、返された復旧方法を示します。
削除対象を推測しません。
クローンが失敗した場合(ネットワークの問題など)、`AskUserQuestion` を使用してユーザーに既存の ECC クローンへのローカルパスを提供するよう依頼します。
### 2. 2 つの選択だけを収集
---
スコープについて 1 回だけ質問し、必ず 1 つの値を選びます。
## ステップ 1: インストールレベルの選択
- `user | project | local`
- `user` はこのユーザーの全プロジェクトで使えます。
- `project` はリポジトリ設定で共有されます。
- `local` は現在のプロジェクトのみに非公開です。
`AskUserQuestion` を使用してユーザーにインストール先を尋ねます:
選択済みまたはインストール中の表示は、実際に選んだ 1 スコープだけにします。唯一の既存スコープと異なる
値を選んだら、スコープ移行であると説明し、以下のコマンドに `--move-scope` を含めます。
```
Question: "ECC コンポーネントをどこにインストールしますか?"
Options:
- "User-level (~/.claude/)" — "すべての Claude Code プロジェクトに適用されます"
- "Project-level (.claude/)" — "現在のプロジェクトのみに適用されます"
- "Both" — "共通/共有アイテムはユーザーレベル、プロジェクト固有アイテムはプロジェクトレベル"
```
フックモードについて 1 回だけ質問し、必ず 1 つの値を選びます。
選択を `INSTALL_LEVEL` として保存します。ターゲットディレクトリを設定します:
- User-level: `TARGET=~/.claude`
- Project-level: `TARGET=.claude`(現在のプロジェクトルートからの相対パス)
- Both: `TARGET_USER=~/.claude``TARGET_PROJECT=.claude`
- `off | minimal | standard | strict`
- `off` はスキルとコマンドを残し、ECC フック自動化を無効にします。
- `minimal` は最軽量のライフサイクルと安全自動化のみを有効にします。
- `standard` は品質と安全のバランスを取ります。
- `strict` は最も強いチェックとリマインダーを有効にします。
ターゲットディレクトリが存在しない場合は作成します:
```bash
mkdir -p $TARGET/skills $TARGET/rules
```
フック設定は個人の Claude プラグイン設定であり、導入スコープには追従しません。
---
### 3. プレビューし、1 回だけ確認
## ステップ 2: スキルの選択とインストール
### 2a: スキルカテゴリの選択
31個のスキルが4つのカテゴリに分類されています。`multiSelect: true``AskUserQuestion` を使用します:
```
Question: "どのスキルカテゴリをインストールしますか?"
Options:
- "Framework & Language" — "Django, Spring Boot, Go, Python, Java, Frontend, Backend パターン"
- "Database" — "PostgreSQL, ClickHouse, JPA/Hibernate パターン"
- "Workflow & Quality" — "TDD, 検証, 学習, セキュリティレビュー, コンパクション"
- "All skills" — "利用可能なすべてのスキルをインストール"
```
### 2b: 個別スキルの確認
選択された各カテゴリについて、以下の完全なスキルリストを表示し、ユーザーに確認または特定のものの選択解除を依頼します。リストが4項目を超える場合、リストをテキストとして表示し、`AskUserQuestion` で「リストされたすべてをインストール」オプションと、ユーザーが特定の名前を貼り付けるための「その他」オプションを使用します。
**カテゴリ: Framework & Language20スキル)**
| スキル | 説明 |
|-------|-------------|
| `backend-patterns` | バックエンドアーキテクチャ、API設計、Node.js/Express/Next.js のサーバーサイドベストプラクティス |
| `coding-standards` | TypeScript、JavaScript、React、Node.js の汎用コーディング標準 |
| `django-patterns` | Django アーキテクチャ、DRF による REST API、ORM、キャッシング、シグナル、ミドルウェア |
| `django-security` | Django セキュリティ: 認証、CSRF、SQL インジェクション、XSS 防止 |
| `django-tdd` | pytest-django、factory_boy、モック、カバレッジによる Django テスト |
| `django-verification` | Django 検証ループ: マイグレーション、リンティング、テスト、セキュリティスキャン |
| `frontend-patterns` | React、Next.js、状態管理、パフォーマンス、UI パターン |
| `golang-patterns` | 慣用的な Go パターン、堅牢な Go アプリケーションのための規約 |
| `golang-testing` | Go テスト: テーブル駆動テスト、サブテスト、ベンチマーク、ファジング |
| `java-coding-standards` | Spring Boot 用 Java コーディング標準: 命名、不変性、Optional、ストリーム |
| `python-patterns` | Pythonic なイディオム、PEP 8、型ヒント、ベストプラクティス |
| `python-testing` | pytest、TDD、フィクスチャ、モック、パラメータ化による Python テスト |
| `quarkus-patterns` | Quarkus アーキテクチャ、Camel メッセージング、CDI サービス、Panache データアクセス |
| `quarkus-security` | Quarkus セキュリティ: JWT/OIDC、RBAC、入力バリデーション、シークレット管理 |
| `quarkus-tdd` | JUnit 5、Mockito、REST Assured、Camel テストによる Quarkus TDD |
| `quarkus-verification` | Quarkus 検証: ビルド、静的解析、テスト、ネイティブコンパイル |
| `springboot-patterns` | Spring Boot アーキテクチャ、REST API、レイヤードサービス、キャッシング、非同期 |
| `springboot-security` | Spring Security: 認証/認可、検証、CSRF、シークレット、レート制限 |
| `springboot-tdd` | JUnit 5、Mockito、MockMvc、Testcontainers による Spring Boot TDD |
| `springboot-verification` | Spring Boot 検証: ビルド、静的解析、テスト、セキュリティスキャン |
**カテゴリ: Database3スキル)**
| スキル | 説明 |
|-------|-------------|
| `clickhouse-io` | ClickHouse パターン、クエリ最適化、分析、データエンジニアリング |
| `jpa-patterns` | JPA/Hibernate エンティティ設計、リレーションシップ、クエリ最適化、トランザクション |
| `postgres-patterns` | PostgreSQL クエリ最適化、スキーマ設計、インデックス作成、セキュリティ |
**カテゴリ: Workflow & Quality8スキル)**
| スキル | 説明 |
|-------|-------------|
| `continuous-learning` | セッションから再利用可能なパターンを学習済みスキルとして自動抽出 |
| `continuous-learning-v2` | 信頼度スコアリングを持つ本能ベースの学習、スキル/コマンド/エージェントに進化 |
| `eval-harness` | 評価駆動開発(EDD)のための正式な評価フレームワーク |
| `iterative-retrieval` | サブエージェントコンテキスト問題のための段階的コンテキスト改善 |
| `security-review` | セキュリティチェックリスト: 認証、入力、シークレット、API、決済機能 |
| `strategic-compact` | 論理的な間隔で手動コンテキスト圧縮を提案 |
| `tdd-workflow` | 80%以上のカバレッジで TDD を強制: ユニット、統合、E2E |
| `verification-loop` | 検証と品質ループのパターン |
**スタンドアロン**
| スキル | 説明 |
|-------|-------------|
| `docs/examples/project-guidelines-template.md` | プロジェクト固有のスキルを作成するためのテンプレート |
### 2c: インストールの実行
選択された各スキルについて、正しいソースルートからスキルディレクトリ全体をコピーします:
プラグイン内蔵 setup スクリプトを優先します。2 つの選択値を代入し、スコープ移行の場合だけ
`--move-scope` を含めます。
```bash
# コアスキルは .agents/skills/ 配下にあります
cp -R "$ECC_ROOT/.agents/skills/<skill-name>" "$TARGET/skills/"
# ニッチスキルは skills/ 配下にあります
cp -R "$ECC_ROOT/skills/<skill-name>" "$TARGET/skills/"
node "$CLAUDE_PLUGIN_ROOT/scripts/setup.js" --mode claude-plugin \
--scope <scope> --hooks <hooks> [--move-scope] --dry-run --json
```
glob で取得したソースディレクトリを処理するときは、trailing slash 付きのソースをそのまま `cp` に渡さないでください。宛先名にディレクトリ名を明示します
`$CLAUDE_PLUGIN_ROOT` がない場合は公開 npm パッケージを使います
```bash
cp -R "${src%/}" "$TARGET/skills/$(basename "${src%/}")"
npx --yes --package ecc-universal ecc setup --mode claude-plugin \
--scope <scope> --hooks <hooks> [--move-scope] --dry-run --json
```
注: `continuous-learning``continuous-learning-v2` には追加ファイル(config.json、フック、スクリプト)があります — SKILL.md だけでなく、ディレクトリ全体がコピーされることを確認してください。
確認サマリーは 1 回だけ表示します。予定アクション、1 スコープ、1 フックモード、marketplace アクション、
および移行元から移行先を含め、yes/no を 1 回だけ質問します。ハーネスの Shell は通常非 TTY のため、
そこで bare な対話式 `ecc setup` を実行しません。
---
### 4. 明示した選択を適用
## ステップ 3: ルールの選択とインストール
`multiSelect: true``AskUserQuestion` を使用します:
```
Question: "どのルールセットをインストールしますか?"
Options:
- "Common rules (Recommended)" — "言語に依存しない原則: コーディングスタイル、git ワークフロー、テスト、セキュリティなど(8ファイル)"
- "TypeScript/JavaScript" — "TS/JS パターン、フック、Playwright によるテスト(5ファイル)"
- "Python" — "Python パターン、pytest、black/ruff フォーマット(5ファイル)"
- "Go" — "Go パターン、テーブル駆動テスト、gofmt/staticcheck5ファイル)"
```
インストールを実行:
```bash
# 共通ルール
cp -r $ECC_ROOT/rules/common $TARGET/rules/common
# 言語固有のルール(言語別ディレクトリを保持)
cp -r $ECC_ROOT/rules/typescript $TARGET/rules/typescript # 選択された場合
cp -r $ECC_ROOT/rules/python $TARGET/rules/python # 選択された場合
cp -r $ECC_ROOT/rules/golang $TARGET/rules/golang # 選択された場合
```
**重要**: ユーザーが言語固有のルールを選択したが、共通ルールを選択しなかった場合、警告します:
> "言語固有のルールは共通ルールを拡張します。共通ルールなしでインストールすると、不完全なカバレッジになる可能性があります。共通ルールもインストールしますか?"
---
## ステップ 4: インストール後の検証
インストール後、以下の自動チェックを実行します:
### 4a: ファイルの存在確認
インストールされたすべてのファイルをリストし、ターゲットロケーションに存在することを確認します:
```bash
ls -la $TARGET/skills/
ls -la $TARGET/rules/
```
### 4b: パス参照のチェック
インストールされたすべての `.md` ファイルでパス参照をスキャンします:
```bash
grep -rn "~/.claude/" $TARGET/skills/ $TARGET/rules/
grep -rn "../common/" $TARGET/rules/
grep -rn "skills/" $TARGET/skills/
```
**プロジェクトレベルのインストールの場合**、`~/.claude/` パスへの参照をフラグします:
- スキルが `~/.claude/settings.json` を参照している場合 — これは通常問題ありません(設定は常にユーザーレベルです)
- スキルが `~/.claude/skills/` または `~/.claude/rules/` を参照している場合 — プロジェクトレベルのみにインストールされている場合、これは壊れている可能性があります
- スキルが別のスキルを名前で参照している場合 — 参照されているスキルもインストールされているか確認します
### 4c: スキル間の相互参照のチェック
一部のスキルは他のスキルを参照します。これらの依存関係を検証します:
- `django-tdd``django-patterns` を参照する可能性があります
- `springboot-tdd``springboot-patterns` を参照する可能性があります
- `continuous-learning-v2``~/.claude/homunculus/` ディレクトリを参照します
- `python-testing``python-patterns` を参照する可能性があります
- `golang-testing``golang-patterns` を参照する可能性があります
- 言語固有のルールは `common/` の対応物を参照します
### 4d: 問題の報告
見つかった各問題について、報告します:
1. **ファイル**: 問題のある参照を含むファイル
2. **行**: 行番号
3. **問題**: 何が間違っているか(例: "~/.claude/skills/python-patterns を参照していますが、python-patterns がインストールされていません")
4. **推奨される修正**: 何をすべきか(例: "python-patterns スキルをインストール" または "パスを .claude/skills/ に更新"
---
## ステップ 5: インストールされたファイルの最適化(オプション)
`AskUserQuestion` を使用します:
```
Question: "インストールされたファイルをプロジェクト用に最適化しますか?"
Options:
- "Optimize skills" — "無関係なセクションを削除、パスを調整、技術スタックに合わせて調整"
- "Optimize rules" — "カバレッジ目標を調整、プロジェクト固有のパターンを追加、ツール設定をカスタマイズ"
- "Optimize both" — "インストールされたすべてのファイルの完全な最適化"
- "Skip" — "すべてをそのまま維持"
```
### スキルを最適化する場合:
1. インストールされた各 SKILL.md を読み取ります
2. ユーザーにプロジェクトの技術スタックを尋ねます(まだ不明な場合)
3. 各スキルについて、無関係なセクションの削除を提案します
4. インストール先(ソースリポジトリではなく)で SKILL.md ファイルをその場で編集します
5. ステップ4で見つかったパスの問題を修正します
### ルールを最適化する場合:
1. インストールされた各ルール .md ファイルを読み取ります
2. ユーザーに設定について尋ねます:
- テストカバレッジ目標(デフォルト80%)
- 優先フォーマットツール
- Git ワークフロー規約
- セキュリティ要件
3. インストール先でルールファイルをその場で編集します
**重要**: インストール先(`$TARGET/`)のファイルのみを変更し、ソース ECC リポジトリ(`$ECC_ROOT/`)のファイルは決して変更しないでください。
---
## ステップ 6: インストールサマリー
`/tmp` からクローンされたリポジトリをクリーンアップします:
確認後、同じ経路を `--dry-run` なしで再実行します。全選択を明示し、JSON で成功を判定します。
```bash
rm -rf /tmp/everything-claude-code
node "$CLAUDE_PLUGIN_ROOT/scripts/setup.js" --mode claude-plugin \
--scope <scope> --hooks <hooks> [--move-scope] --yes --json
```
次にサマリーレポートを出力します:
フォールバック:
```
## ECC インストール完了
### インストール先
- レベル: [user-level / project-level / both]
- パス: [ターゲットパス]
### インストールされたスキル([数])
- skill-1, skill-2, skill-3, ...
### インストールされたルール([数])
- common8ファイル)
- typescript5ファイル)
- ...
### 検証結果
- [数]個の問題が見つかり、[数]個が修正されました
- [残っている問題をリスト]
### 適用された最適化
- [加えられた変更をリスト、または "なし"]
```bash
npx --yes --package ecc-universal ecc setup --mode claude-plugin \
--scope <scope> --hooks <hooks> [--move-scope] --yes --json
```
---
### 5. 検証後にウェルカムを表示
## トラブルシューティング
終了コードが 0 であり、setup 結果の `scope``hooks` が選択値と一致することを必須とします。
その後、独立して実行します。
### "スキルが Claude Code に認識されません"
- スキルディレクトリに `SKILL.md` ファイルが含まれていることを確認します(単なる緩い .md ファイルではありません)
- ユーザーレベルの場合: `~/.claude/skills/<skill-name>/SKILL.md` が存在するか確認します
- プロジェクトレベルの場合: `.claude/skills/<skill-name>/SKILL.md` が存在するか確認します
```bash
claude plugin list --json
```
### "ルールが機能しません"
- ルールはフラットファイルで、サブディレクトリにはありません: `$TARGET/rules/coding-style.md`(正しい) vs `$TARGET/rules/common/coding-style.md`(フラットインストールでは不正)
- ルールをインストール後、Claude Code を再起動します
選択スコープに有効な `ecc@ecc` が正確に 1 件ある場合のみ続行します。`$CLAUDE_PLUGIN_ROOT` があるときは、
成功した setup の `action``installed``updated``migrated``resumed`
`already-migrated`)を内蔵レンダラーへ渡します
### "プロジェクトレベルのインストール後のパス参照エラー"
- 一部のスキルは `~/.claude/` パスを前提としています。ステップ4の検証を実行してこれらを見つけて修正します。
- `continuous-learning-v2` の場合、`~/.claude/homunculus/` ディレクトリは常にユーザーレベルです — これは想定されており、エラーではありません
呼び出し前に、プロバイダーが報告したバージョンが
`scripts/lib/terminal-welcome.js``ECC_VERSION_PATTERN` に一致することを
確認します。予期しない値は shell に補間せず拒否してください
```bash
node -e 'const { renderTerminalWelcome } = require(process.env.CLAUDE_PLUGIN_ROOT + "/scripts/lib/terminal-welcome"); process.stdout.write(renderTerminalWelcome({ action: process.argv[1], version: process.argv[2], color: process.stdout.isTTY }));' "<action>" "<installed-version>"
```
ウェルカムは 1 回だけ表示します。失敗、dry-run、キャンセル、スコープ/フック不一致、検証不能の場合は
表示せず、エラーと復旧手順を報告します。検証後は `/reload-plugins` または Claude Code の再起動を案内します。
## Codex: ネイティブプラグインライフサイクル
`codex plugin marketplace list --json``codex plugin list --available --json` で確認します。
Codex ネイティブのプラグインコマンドには Claude 式 `user | project | local` 選択はありません。
Claude のスコープ/フック 4 段階は質問しません。Codex ネイティブプラグインはプロバイダー固有フックに対応しますが、
Codex はその明示的な信頼を求めます。Codex にその信頼判断を表示させ、Claude の 4 プロファイルが Codex に対応すると表現しません。
ECC marketplace がない場合は追加し、既存ならスナップショットを更新します。
```bash
codex plugin marketplace add affaan-m/ECC
codex plugin marketplace upgrade ecc --json
```
1 回だけ確認し、インストールまたは導入済みキャッシュの再現可能な更新を行い、検証します。
```bash
codex plugin add ecc@ecc --json
codex plugin list --json
```
JSON が ECC を導入済みと報告し、`installedPath` を提供した場合のみ続行し、検証済みバンドルからウェルカムを表示します。
`installedPath` は Codex JSON が返した絶対パスそのものだけを使い、制御文字を
拒否します。バージョンは `ECC_VERSION_PATTERN` で検証します。`node` を次の
argument array で直接呼び出してください。これは shell コマンドではなく、ツール API 呼び出しです。
```text
["<installedPath>/scripts/welcome.js", "--action", "configured", "--version", "<installed-version>"]
```
現在のハーネスが実行ファイルと argument array を分けて渡せない場合は、ウェルカム表示を
スキップします。Codex JSON の値から shell コマンドを組み立ててはいけません。
Claude の `off | minimal | standard | strict` が Codex に適用されたとは表現しません。
## Kimi: プロジェクトサーフェス
確認前に機能サマリーを示します。導入先は `./.kimi-code`、ECC ライフサイクルフックは
`hooks=unsupported` です。Claude のスコープ/フックモードを質問しません。まずプレビューします。
```bash
npx --yes --package ecc-universal ecc install --profile core --target kimi --dry-run
```
このプロジェクト導入先について 1 回だけ確認し、`--dry-run` を除いた同一コマンドを適用します。
検証コマンド:
```bash
npx --yes --package ecc-universal ecc doctor --target kimi
```
doctor が成功し、導入された指示とスキルが `./.kimi-code` 内に留まることを確認した後だけ実行します。
```bash
npx --yes --package ecc-universal ecc welcome --action configured
```
Kimi が ECC ライフサイクルフックを導入または設定したとは表現しません。
+4
View File
@@ -80,6 +80,10 @@ Este repositório contém apenas o código. Os guias explicam tudo.
## O Que Há de Novo
### v2.2.0 — Instalação Guiada para Múltiplos Harnesses (Ago 2026)
Adiciona uma instalação revisável para Claude Code, Codex e Kimi Code, com uma entrada de comando npm sincronizada.
### v2.1.0 — O Sistema Operacional do Harness de Agentes (Jun 2026)
Graduação estável da linha 2.0: 261 skills, substrato de control-pane, inventário MCP, serviço de ciclo de vida de worktrees e a comunidade no [Discord](https://discord.gg/36yGMHGFbR).
+5 -5
View File
@@ -1,8 +1,8 @@
# Everything Claude Code (ECC) — Agent Talimatları
Bu, yazılım geliştirme için 28 özel agent, 116 skill, 59 command ve otomatik hook iş akışları sağlayan **üretime hazır bir AI kodlama eklentisidir**.
Bu, yazılım geliştirme için 67 özel agent, 281 skill, 94 command ve otomatik hook iş akışları sağlayan **üretime hazır bir AI kodlama eklentisidir**.
**Sürüm:** 2.1.0
**Sürüm:** 2.2.0
## Temel İlkeler
@@ -141,9 +141,9 @@ Başarısızlık sorunlarını giderin: test izolasyonunu kontrol edin → mockl
## Proje Yapısı
```
agents/ — 28 özel subagent
skills/ — 115 iş akışı skillleri ve alan bilgisi
commands/ — 59 slash command
agents/ — 67 özel subagent
skills/ — 281 iş akışı skillleri ve alan bilgisi
commands/ — 94 slash command
hooks/ — Tetikleyici tabanlı otomasyonlar
rules/ — Her zaman uyulması gereken kurallar (ortak + dile özel)
scripts/ — Platformlar arası Node.js yardımcı programları
+4
View File
@@ -79,6 +79,10 @@ Bu repository yalnızca ham kodu içerir. Rehberler her şeyi açıklıyor.
## Yenilikler
### v2.2.0 — Rehberli Çoklu Harness Kurulumu (Ağu 2026)
Claude Code, Codex ve Kimi Code için incelenebilir çoklu harness kurulumu ve eşitlenmiş npm komut girişi eklendi.
### v2.1.0 — Ajan Harness İşletim Sistemi (Haz 2026)
2.0 hattının kararlı sürümü: 261 skill, control-pane altyapısı, MCP envanteri, worktree yaşam döngüsü servisi ve [Discord topluluğu](https://discord.gg/36yGMHGFbR).
+1 -1
View File
@@ -2,7 +2,7 @@
这是一个**生产就绪的 AI 编码插件**,提供 67 个专业代理、281 项技能、94 条命令以及自动化钩子工作流,用于软件开发。
**版本:** 2.1.0
**版本:** 2.2.0
## 核心原则
+8 -4
View File
@@ -81,6 +81,10 @@
## 最新动态
### v2.2.0 — 引导式多 Harness 安装(2026年8月)
新增可审查的 Claude Code、Codex 与 Kimi Code 多 Harness 安装流程,并提供同步的 npm 命令入口。
### v2.1.0 — 智能体 Harness 操作系统(2026年6月)
2.0 主线稳定版:261 个技能、control-pane 基底(会话适配器 + MCP 清单)、worktree 生命周期服务,以及 [ECC Discord 社区](https://discord.gg/36yGMHGFbR)。
@@ -1279,8 +1283,8 @@ ECC 是**第一个最大化利用每个主要 AI 编码工具的插件**。以
| **智能体** | 67 | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 |
| **命令** | 94 | 共享 | 基于指令 | 35 |
| **技能** | 281 | 共享 | 10 (原生格式) | 37 |
| **钩子事件** | 8 种类型 | 15 种类型 | 暂无 | 11 种类型 |
| **钩子脚本** | 20+ 个脚本 | 16 个脚本 (DRY 适配器) | N/A | 插件钩子 |
| **钩子事件** | 8 种类型 | 15 种类型 | SessionStart1 种类型) | 11 种类型 |
| **钩子脚本** | 20+ 个脚本 | 16 个脚本 (DRY 适配器) | 1 个 SessionStart 引导脚本 | 插件钩子 |
| **规则** | 34 (通用 + 语言) | 34 (YAML 前页) | 基于指令 | 13 条指令 |
| **自定义工具** | 通过钩子 | 通过钩子 | N/A | 6 个原生工具 |
| **MCP 服务器** | 14 | 共享 (mcp.json) | 4 (基于命令) | 完整 |
@@ -1288,14 +1292,14 @@ ECC 是**第一个最大化利用每个主要 AI 编码工具的插件**。以
| **上下文文件** | CLAUDE.md + AGENTS.md | AGENTS.md | AGENTS.md | AGENTS.md |
| **秘密检测** | 基于钩子 | beforeSubmitPrompt 钩子 | 基于沙箱 | 基于钩子 |
| **自动格式化** | PostToolUse 钩子 | afterFileEdit 钩子 | N/A | file.edited 钩子 |
| **版本** | 插件 | 插件 | 参考配置 | 2.1.0 |
| **版本** | 插件 | 插件 | 参考配置 | 2.2.0 |
**关键架构决策:**
* **AGENTS.md** 在根目录是通用的跨工具文件(所有 4 个工具都能读取)
* **DRY 适配器模式** 让 Cursor 可以重用 Claude Code 的钩子脚本而无需重复
* **技能格式**(带有 YAML 前言的 SKILL.md)在 Claude Code、Codex 和 OpenCode 中都能工作
* Codex 缺少钩子功能,通过 `AGENTS.md`、可选的 `model_instructions_file` 覆盖以及沙箱权限来弥补
* Codex 通过原生 `SessionStart` 引导钩子初始化 ECC;其余行为由 `AGENTS.md`、可选的 `model_instructions_file` 覆盖以及沙箱权限提供
***
+114 -333
View File
@@ -1,400 +1,181 @@
---
name: configure-ecc
description: Everything Claude Code 的交互式安装程序 — 引导用户选择并安装技能和规则到用户级或项目级目录,验证路径,并可选择优化已安装文件
origin: ECC
description: Claude Code、Codex 或 Kimi 内引导 ECC 安装、更新或重新配置,同时严格遵守各家工具真实的插件、范围和 Hook 能力
metadata:
origin: ECC
---
# 配置 Everything Claude Code (ECC)
# 配置 Everything Claude Code
一个交互式、分步安装向导,用于 Everything Claude Code 项目。使用 `AskUserQuestion` 引导用户选择性安装技能和规则,然后验证正确性并提供优化。
在当前工具内运行对话式向导:先检查,只收集受支持的选项,预览,只确认
一次,以非交互方式执行,验证,最后才显示欢迎信息。不要把 ECC 克隆到
临时目录,也不要手动复制插件组件。
## 何时激活
在用户自己操作的终端中,规范入口是 `ecc setup``npx ecc-universal setup`
在工具内请改用下方参数完整的非交互命令。
* 用户说 "configure ecc"、"install ecc"、"setup everything claude code" 或类似表述
* 用户想要从此项目中选择性安装技能或规则
* 用户想要验证或修复现有的 ECC 安装
* 用户想要为其项目优化已安装的技能或规则
## 按当前工具分流
## 先决条件
- Claude Code:使用下面完整的范围与 Hook 向导。
- Codex:使用 Codex 原生插件生命周期;不要提供 Claude 范围,也不要映射
Claude 的四种 ECC Hook 配置。
- Kimi:把项目表面安装到 `./.kimi-code`Kimi 不支持 ECC 的 Claude 生命周期
Hook 配置。
- 无法确定工具时,先说明检测依据,再询问要配置哪一个,不要直接修改。
此技能必须在激活前对 Claude Code 可访问。有两种引导方式:
此技能是安装后的重新配置路径,无法拦截或取代提供商内置的首次安装界面。
1. **通过插件**: `/plugin install ecc@ecc` — 插件会自动加载此技能
2. **手动**: 仅将此技能复制到 `~/.claude/skills/configure-ecc/SKILL.md`,然后通过说 "configure ecc" 激活
## Claude Code:运行完整对话式向导
***
### 1. 只读检查
## 步骤 0:克隆 ECC 仓库
在任何安装之前,将最新的 ECC 源代码克隆到 `/tmp`
运行以下两条命令,总结 ECC 的安装范围、启用状态和 marketplace 来源:
```bash
rm -rf /tmp/everything-claude-code
git clone https://github.com/affaan-m/everything-claude-code.git /tmp/everything-claude-code
claude plugin list --json
claude plugin marketplace list --json
```
`ECC_ROOT=/tmp/everything-claude-code` 设置为所有后续复制操作的源。
只有一个现有 `ecc@ecc` 时,将本次视为重新配置。不要把 Claude 提供商所有的
“Open home page”控件当作安装证据。若 setup 报告多个 ECC 范围、旧版或手动
安装、配置损坏或 marketplace 冲突,请停止并原样报告恢复建议,不要猜测要删除哪个。
如果克隆失败(网络问题等),使用 `AskUserQuestion` 要求用户提供现有 ECC 克隆的本地路径。
### 2. 只收集两个选择
***
只询问一次安装范围,并要求且仅要求一个值:
## 步骤 1:选择安装级别
- `user | project | local`
- `user` 对当前用户全局可用。
- `project` 通过仓库设置共享。
- `local` 仅当前项目私有。
使用 `AskUserQuestion` 询问用户安装位置:
界面中只能把选中的一个范围显示为已选或正在安装。如果用户从唯一现有范围
切换到另一范围,说明这是范围迁移,并在下方命令中加入 `--move-scope`
```
问题:"ECC组件应安装在哪里?"
选项:
- "用户级别 (~/.claude/)" — "适用于您所有的Claude Code项目"
- "项目级别 (.claude/)" — "仅适用于当前项目"
- "两者" — "通用/共享项在用户级别,项目特定项在项目级别"
```
只询问一次 Hook 模式,并要求且仅要求一个值:
将选择存储为 `INSTALL_LEVEL`。设置目标目录:
- `off | minimal | standard | strict`
- `off` 保留技能和命令,但关闭 ECC Hook 自动化。
- `minimal` 只启用最轻量的生命周期和安全自动化。
- `standard` 平衡质量和安全自动化。
- `strict` 启用最严格的检查和提醒。
* 用户级别:`TARGET=~/.claude`
* 项目级别:`TARGET=.claude`(相对于当前项目根目录)
* 两者:`TARGET_USER=~/.claude``TARGET_PROJECT=.claude`
Hook 偏好是个人 Claude 插件配置,不会跟随所选安装范围。
如果目标目录不存在,则创建它们:
### 3. 预览并只确认一次
优先使用插件自带的 setup 脚本。替换两个已选值,只在范围迁移时加入
`--move-scope`
```bash
mkdir -p $TARGET/skills $TARGET/rules
node "$CLAUDE_PLUGIN_ROOT/scripts/setup.js" --mode claude-plugin \
--scope <scope> --hooks <hooks> [--move-scope] --dry-run --json
```
***
## 步骤 2:选择并安装技能
### 2a: 选择范围(核心 vs 细分领域)
默认为 **核心(推荐给新用户)** — 对于研究优先的工作流,复制 `.agents/skills/*` 加上 `skills/search-first/`。此捆绑包涵盖工程、评估、验证、安全、战略压缩、前端设计以及 Anthropic 跨职能技能(文章写作、内容引擎、市场研究、前端幻灯片)。
使用 `AskUserQuestion`(单选):
```
问题:"只安装核心技能,还是包含小众/框架包?"
选项:
- "仅核心(推荐)" — "tdd, e2e, evals, verification, research-first, security, frontend patterns, compacting, cross-functional Anthropic skills"
- "核心 + 精选小众" — "在核心基础上添加框架/领域特定技能"
- "仅小众" — "跳过核心,安装特定框架/领域技能"
默认:仅核心
```
如果用户选择细分领域或核心 + 细分领域,则继续下面的类别选择,并且仅包含他们选择的那些细分领域技能。
### 2b: 选择技能类别
下方有7个可选的类别组。后续的详细确认列表涵盖了8个类别中的45项技能,外加1个独立模板。使用 `AskUserQuestion``multiSelect: true`
```
问题:“您希望安装哪些技能类别?”
选项:
- “框架与语言” — “Django, Laravel, Spring Boot, Go, Python, Java, 前端, 后端模式”
- “数据库” — “PostgreSQL, ClickHouse, JPA/Hibernate 模式”
- “工作流与质量” — “TDD, 验证, 学习, 安全审查, 压缩”
- “研究与 API” — “深度研究, Exa 搜索, Claude API 模式”
- “社交与内容分发” — “X/Twitter API, 内容引擎并行交叉发布”
- “媒体生成” — “fal.ai 图像/视频/音频与 VideoDB 并行”
- “编排” — “dmux 多智能体工作流”
- “所有技能” — “安装所有可用技能”
```
### 2c: 确认个人技能
对于每个选定的类别,打印下面的完整技能列表,并要求用户确认或取消选择特定的技能。如果列表超过 4 项,将列表打印为文本,并使用 `AskUserQuestion`,提供一个 "安装所有列出项" 的选项,以及一个 "其他" 选项供用户粘贴特定名称。
**类别:框架与语言(21项技能)**
| 技能 | 描述 |
|-------|-------------|
| `backend-patterns` | Node.js/Express/Next.js 的后端架构、API 设计、服务器端最佳实践 |
| `coding-standards` | TypeScript、JavaScript、React、Node.js 的通用编码标准 |
| `django-patterns` | Django 架构、使用 DRF 的 REST API、ORM、缓存、信号、中间件 |
| `django-security` | Django 安全性:认证、CSRF、SQL 注入、XSS 防护 |
| `django-tdd` | 使用 pytest-django、factory\_boy、模拟、覆盖率进行 Django 测试 |
| `django-verification` | Django 验证循环:迁移、代码检查、测试、安全扫描 |
| `laravel-patterns` | Laravel 架构模式:路由、控制器、Eloquent、队列、缓存 |
| `laravel-security` | Laravel 安全性:认证、策略、CSRF、批量赋值、速率限制 |
| `laravel-tdd` | 使用 PHPUnit 和 Pest、工厂、假对象、覆盖率进行 Laravel 测试 |
| `laravel-verification` | Laravel 验证:代码检查、静态分析、测试、安全扫描 |
| `frontend-patterns` | React、Next.js、状态管理、性能、UI 模式 |
| `frontend-slides` | 零依赖的 HTML 演示文稿、样式预览以及 PPTX 到网页的转换 |
| `golang-patterns` | 地道的 Go 模式、构建稳健 Go 应用程序的约定 |
| `golang-testing` | Go 测试:表驱动测试、子测试、基准测试、模糊测试 |
| `java-coding-standards` | Spring Boot 的 Java 编码标准:命名、不可变性、Optional、流 |
| `python-patterns` | Pythonic 惯用法、PEP 8、类型提示、最佳实践 |
| `python-testing` | 使用 pytest、TDD、夹具、模拟、参数化进行 Python 测试 |
| `quarkus-patterns` | Quarkus 架构、使用 Camel 的事件驱动模式、Panache 数据访问、CDI 服务 |
| `quarkus-security` | Quarkus 安全:JWT/OIDC 认证、RBAC、Bean 验证、CORS、密钥管理 |
| `quarkus-tdd` | 使用 JUnit 5、Mockito、REST Assured、Camel 测试进行 Quarkus TDD |
| `quarkus-verification` | Quarkus 验证:构建、静态分析、测试、安全扫描、原生编译 |
| `springboot-patterns` | Spring Boot 架构、REST API、分层服务、缓存、异步处理 |
| `springboot-security` | Spring Security:认证/授权、验证、CSRF、密钥、速率限制 |
| `springboot-tdd` | 使用 JUnit 5、Mockito、MockMvc、Testcontainers 进行 Spring Boot TDD |
| `springboot-verification` | Spring Boot 验证:构建、静态分析、测试、安全扫描 |
**类别:数据库(3 项技能)**
| 技能 | 描述 |
|-------|-------------|
| `clickhouse-io` | ClickHouse 模式、查询优化、分析、数据工程 |
| `jpa-patterns` | JPA/Hibernate 实体设计、关系、查询优化、事务 |
| `postgres-patterns` | PostgreSQL 查询优化、模式设计、索引、安全 |
**类别:工作流与质量(8 项技能)**
| 技能 | 描述 |
|-------|-------------|
| `continuous-learning` | 从会话中自动提取可重用模式作为习得技能 |
| `continuous-learning-v2` | 基于本能的学习,带有置信度评分,演变为技能/命令/代理 |
| `eval-harness` | 用于评估驱动开发 (EDD) 的正式评估框架 |
| `iterative-retrieval` | 用于子代理上下文问题的渐进式上下文优化 |
| `security-review` | 安全检查清单:身份验证、输入、密钥、API、支付功能 |
| `strategic-compact` | 在逻辑间隔处建议手动上下文压缩 |
| `tdd-workflow` | 强制要求 TDD,覆盖率 80% 以上:单元测试、集成测试、端到端测试 |
| `verification-loop` | 验证和质量循环模式 |
**类别:业务与内容(5 项技能)**
| 技能 | 描述 |
|-------|-------------|
| `article-writing` | 使用笔记、示例或源文档,以指定的口吻进行长篇写作 |
| `content-engine` | 多平台社交内容、脚本和内容再利用工作流 |
| `market-research` | 带有来源标注的市场、竞争对手、基金和技术研究 |
| `investor-materials` | 宣传文稿、一页简介、投资者备忘录和财务模型 |
| `investor-outreach` | 个性化的投资者冷邮件、熟人介绍和后续跟进 |
**类别:研究与API2项技能)**
| 技能 | 描述 |
|-------|-------------|
| `deep-research` | 使用 firecrawl 和 exa MCP 进行多源深度研究,并生成带引用的报告 |
| `exa-search` | 通过 Exa MCP 进行网络、代码、公司和人员的神经搜索 |
`claude-api` 是 Anthropic 官方技能;需要时请从 [`anthropics/skills`](https://github.com/anthropics/skills) 安装官方版本,而不是通过 ECC 重复打包。
**类别:社交与内容分发(2项技能)**
| 技能 | 描述 |
|-------|-------------|
| `x-api` | X/Twitter API 集成,用于发帖、线程、搜索和分析 |
| `crosspost` | 多平台内容分发,并进行平台原生适配 |
**类别:媒体生成(2项技能)**
| 技能 | 描述 |
|-------|-------------|
| `fal-ai-media` | 通过 fal.ai MCP 进行统一的AI媒体生成(图像、视频、音频) |
| `video-editing` | AI辅助视频编辑,用于剪辑、结构化和增强实拍素材 |
**类别:编排(1项技能)**
| 技能 | 描述 |
|-------|-------------|
| `dmux-workflows` | 使用 dmux 进行多智能体编排,实现并行智能体会话 |
**独立技能**
| 技能 | 描述 |
|-------|-------------|
| `docs/examples/project-guidelines-template.md` | 用于创建项目特定技能的模板 |
### 2d: 执行安装
对于每个选定的技能,请从正确的源目录复制整个技能目录:
如果 `$CLAUDE_PLUGIN_ROOT` 不可用,使用已发布的 npm 包:
```bash
# 核心技能位于 .agents/skills/
cp -R "$ECC_ROOT/.agents/skills/<skill-name>" "$TARGET/skills/"
# 细分技能位于 skills/
cp -R "$ECC_ROOT/skills/<skill-name>" "$TARGET/skills/"
npx --yes --package ecc-universal ecc setup --mode claude-plugin \
--scope <scope> --hooks <hooks> [--move-scope] --dry-run --json
```
遍历 glob 得到的源目录时,不要把带 trailing slash 的源路径直接传给 `cp`。显式使用目录名作为目标名:
只显示一次确认摘要,内容包含计划操作、唯一范围、唯一 Hook 模式、marketplace 操作和
任何从来源到目标的迁移。只问一个是/否问题。不要通过工具的 Shell 调用不带参数的
交互式 `ecc setup`,因为该 Shell 通常不是 TTY。
### 4. 应用明确选择
确认后,使用同一路径但去掉 `--dry-run`。保留每个明确选择,并请求 JSON
```bash
cp -R "${src%/}" "$TARGET/skills/$(basename "${src%/}")"
node "$CLAUDE_PLUGIN_ROOT/scripts/setup.js" --mode claude-plugin \
--scope <scope> --hooks <hooks> [--move-scope] --yes --json
```
注意:`continuous-learning``continuous-learning-v2` 有额外的文件(config.json、钩子、脚本)——确保复制整个目录,而不仅仅是 SKILL.md。
***
## 步骤 3:选择并安装规则
使用 `AskUserQuestion``multiSelect: true`
```
问题:"您希望安装哪些规则集?"
选项:
- "通用规则(推荐)" — "语言无关原则:编码风格、Git工作流、测试、安全等(8个文件)"
- "TypeScript/JavaScript" — "TS/JS模式、钩子、Playwright测试(5个文件)"
- "Python" — "Python模式、pytest、black/ruff格式化(5个文件)"
- "Go" — "Go模式、表驱动测试、gofmt/staticcheck5个文件)"
```
执行安装:
备用命令:
```bash
# Common rules
cp -r $ECC_ROOT/rules/common $TARGET/rules/common
# Language-specific rules (preserve per-language directories)
cp -r $ECC_ROOT/rules/typescript $TARGET/rules/typescript # if selected
cp -r $ECC_ROOT/rules/python $TARGET/rules/python # if selected
cp -r $ECC_ROOT/rules/golang $TARGET/rules/golang # if selected
npx --yes --package ecc-universal ecc setup --mode claude-plugin \
--scope <scope> --hooks <hooks> [--move-scope] --yes --json
```
**重要**:如果用户选择了任何特定语言的规则但**没有**选择通用规则,警告他们:
### 5. 先验证,再显示欢迎信息
> "特定语言规则扩展了通用规则。不安装通用规则可能导致覆盖不完整。是否也安装通用规则?"
***
## 步骤 4:安装后验证
安装后,执行这些自动化检查:
### 4a:验证文件存在
列出所有已安装的文件并确认它们存在于目标位置:
必须得到零退出状态,且 setup 结果中的 `scope``hooks` 必须等于所选值。然后独立运行:
```bash
ls -la $TARGET/skills/
ls -la $TARGET/rules/
claude plugin list --json
```
### 4b:检查路径引用
只有在所选范围中恰好存在一个已启用的 `ecc@ecc` 条目时才继续。如果
`$CLAUDE_PLUGIN_ROOT` 可用,把成功 setup 的 `action``installed``updated`
`migrated``resumed``already-migrated`)传给内置渲染器:
扫描所有已安装的 `.md` 文件中的路径引用:
调用前必须确认提供方报告的版本匹配 `scripts/lib/terminal-welcome.js` 中的
`ECC_VERSION_PATTERN`。异常版本文本应被拒绝,不得插入 shell 命令。
```bash
grep -rn "~/.claude/" $TARGET/skills/ $TARGET/rules/
grep -rn "../common/" $TARGET/rules/
grep -rn "skills/" $TARGET/skills/
node -e 'const { renderTerminalWelcome } = require(process.env.CLAUDE_PLUGIN_ROOT + "/scripts/lib/terminal-welcome"); process.stdout.write(renderTerminalWelcome({ action: process.argv[1], version: process.argv[2], color: process.stdout.isTTY }));' "<action>" "<installed-version>"
```
**对于项目级别安装**,标记任何对 `~/.claude/` 路径的引用:
欢迎信息只渲染一次。失败、预览、取消、范围或 Hook 不匹配、无法验证时都不显示;
改为报告错误和恢复方法。验证完成后,提醒用户运行 `/reload-plugins` 或重启 Claude Code。
* 如果技能引用 `~/.claude/settings.json` — 这通常没问题(设置始终是用户级别的)
* 如果技能引用 `~/.claude/skills/``~/.claude/rules/` — 如果仅安装在项目级别,这可能损坏
* 如果技能通过名称引用另一项技能 — 检查被引用的技能是否也已安装
## Codex:使用原生插件生命周期
### 4c:检查技能间的交叉引用
使用 `codex plugin marketplace list --json``codex plugin list --available --json` 检查。
Codex 的原生插件命令没有 Claude 式 `user | project | local` 选择器。不要询问 Claude 范围或
Hook 四档模式。Codex 原生插件支持提供商专用 Hook,但 Codex 会要求用户明确信任。让 Codex
显示该信任决定;不要声称 Claude 的四种配置可以映射到 Codex。
有些技能会引用其他技能。验证这些依赖关系
* `django-tdd` 可能会引用 `django-patterns`
* `laravel-tdd` 可能会引用 `laravel-patterns`
* `quarkus-tdd` 可能会引用 `quarkus-patterns`
* `springboot-tdd` 可能会引用 `springboot-patterns`
* `continuous-learning-v2` 引用 `~/.claude/homunculus/` 目录
* `python-testing` 可能会引用 `python-patterns`
* `golang-testing` 可能会引用 `golang-patterns`
* `crosspost` 引用 `content-engine``x-api`
* `deep-research` 引用 `exa-search`(补充的 MCP 工具)
* `fal-ai-media` 引用 `videodb`(补充的媒体技能)
* `x-api` 引用 `content-engine``crosspost`
* 特定语言的规则引用 `common/` 的对应内容
### 4d:报告问题
对于发现的每个问题,报告:
1. **文件**:包含问题引用的文件
2. **行号**:行号
3. **问题**:哪里出错了(例如,"引用了 ~/.claude/skills/python-patterns 但 python-patterns 未安装"
4. **建议的修复**:该怎么做(例如,"安装 python-patterns 技能" 或 "将路径更新为 .claude/skills/"
***
## 步骤 5:优化已安装文件(可选)
使用 `AskUserQuestion`
```
问题:"您想要优化项目中的已安装文件吗?"
选项:
- "优化技能" — "移除无关部分,调整路径,适配您的技术栈"
- "优化规则" — "调整覆盖目标,添加项目特定模式,自定义工具配置"
- "两者都优化" — "对所有已安装文件进行全面优化"
- "跳过" — "保持原样不变"
```
### 如果优化技能:
1. 读取每个已安装的 SKILL.md
2. 询问用户其项目的技术栈是什么(如果尚不清楚)
3. 对于每项技能,建议删除无关部分
4. 在安装目标处就地编辑 SKILL.md 文件(**不是**源仓库)
5. 修复在步骤 4 中发现的任何路径问题
### 如果优化规则:
1. 读取每个已安装的规则 .md 文件
2. 询问用户的偏好:
* 测试覆盖率目标(默认 80%)
* 首选的格式化工具
* Git 工作流约定
* 安全要求
3. 在安装目标处就地编辑规则文件
**关键**:只修改安装目标(`$TARGET/`)中的文件,**绝不**修改源 ECC 仓库(`$ECC_ROOT/`)中的文件。
***
## 步骤 6:安装摘要
`/tmp` 清理克隆的仓库:
如果缺少 ECC marketplace,请添加;否则刷新快照
```bash
rm -rf /tmp/everything-claude-code
codex plugin marketplace add affaan-m/ECC
codex plugin marketplace upgrade ecc --json
```
然后打印摘要报告
只确认一次,然后安装或幂等刷新已安装缓存,并验证
```
## ECC 安装完成
### 安装目标
- 级别:[用户级别 / 项目级别 / 两者]
- 路径:[目标路径]
### 已安装技能 ([数量])
- 技能-1, 技能-2, 技能-3, ...
### 已安装规则 ([数量])
- 通用规则 (8 个文件)
- TypeScript 规则 (5 个文件)
- ...
### 验证结果
- 发现 [数量] 个问题,已修复 [数量] 个
- [列出任何剩余问题]
### 已应用的优化
- [列出所做的更改,或 "无"]
```bash
codex plugin add ecc@ecc --json
codex plugin list --json
```
***
只有 JSON 报告 ECC 已安装并提供 `installedPath` 时才继续,然后渲染已验证组合包的欢迎信息:
## 故障排除
`installedPath` 只能使用 Codex JSON 返回的原始绝对路径,并拒绝控制字符。版本必须通过
`ECC_VERSION_PATTERN` 验证。请使用下面的 argument array 直接调用 `node`;这是工具 API
调用,不是 shell 命令:
### "Claude Code 未获取技能"
```text
["<installedPath>/scripts/welcome.js", "--action", "configured", "--version", "<installed-version>"]
```
* 验证技能目录包含一个 `SKILL.md` 文件(不仅仅是松散的 .md 文件)
* 对于用户级别:检查 `~/.claude/skills/<skill-name>/SKILL.md` 是否存在
* 对于项目级别:检查 `.claude/skills/<skill-name>/SKILL.md` 是否存在
如果当前工具无法把可执行文件与 argument array 分开传递,请跳过欢迎信息。不得使用 Codex
JSON 中的值构造 shell 命令。
### "规则不工作"
绝不要声称 Claude 的 `off | minimal | standard | strict` 配置已应用到 Codex。
* 规则是平面文件,不在子目录中:`$TARGET/rules/coding-style.md`(正确)对比 `$TARGET/rules/common/coding-style.md`(对于平面安装不正确)
* 安装规则后重启 Claude Code
## Kimi:安装项目表面
### "项目级别安装后出现路径引用错误"
确认前说明能力摘要:目标为 `./.kimi-code`ECC 生命周期 Hook 为 `hooks=unsupported`
不要询问 Claude 范围或 Hook 模式。先预览:
* 有些技能假设 `~/.claude/` 路径。运行步骤 4 验证来查找并修复这些问题。
* 对于 `continuous-learning-v2``~/.claude/homunculus/` 目录始终是用户级别的 — 这是预期的,不是错误。
```bash
npx --yes --package ecc-universal ecc install --profile core --target kimi --dry-run
```
只针对该项目目标确认一次,然后执行去掉 `--dry-run` 的同一命令。使用以下命令验证:
```bash
npx --yes --package ecc-universal ecc doctor --target kimi
```
只有 doctor 成功,且已安装的指令和技能仍位于 `./.kimi-code` 内时才运行:
```bash
npx --yes --package ecc-universal ecc welcome --action configured
```
不要声称 Kimi 已安装或配置 ECC 生命周期 Hook。
+11 -1
View File
@@ -97,6 +97,9 @@ Remove or comment out the hook entry in `hooks.json`. If installed as a plugin,
Use environment variables to control hook behavior without editing `hooks.json`:
```bash
# Master switch. Explicit environment values override plugin preferences.
export ECC_HOOKS_ENABLED=true
# minimal | standard | strict (default: standard)
export ECC_HOOK_PROFILE=standard
@@ -122,11 +125,18 @@ Windows PowerShell:
[Environment]::SetEnvironmentVariable('ECC_CONTEXT_MONITOR_COST_WARNINGS', 'off', 'User')
```
Profiles:
Claude setup-only value:
- `off` — disables local ECC hook work through `ecc setup`; it is not a runtime hook profile.
Runtime hook profiles:
- `minimal` — keep essential lifecycle and safety hooks only.
- `standard` — default; balanced quality + safety checks.
- `strict` — enables additional reminders and stricter guardrails.
The Claude plugin exposes the same choices as the personal `hooks_enabled` and
`hook_profile` settings. Run `ecc setup --mode claude-plugin` to install or
update the plugin and change those preferences.
### Writing Your Own Hook
Hooks are shell commands that receive tool input as JSON on stdin and must output JSON on stdout.
+18
View File
@@ -0,0 +1,18 @@
{
"description": "ECC native Codex hook: verified SessionStart bootstrap. Claude hook profiles remain separate.",
"hooks": {
"SessionStart": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "node -e \"if(!process.env.PLUGIN_ROOT)throw new Error('Missing Codex PLUGIN_ROOT');process.env.CLAUDE_PLUGIN_ROOT=process.env.PLUGIN_ROOT;const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/session-start-bootstrap.js"
}
],
"description": "Load previous context and detect package manager on new session",
"id": "session:start"
}
]
}
}
+3 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ecc-universal",
"version": "2.1.0",
"version": "2.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ecc-universal",
"version": "2.1.0",
"version": "2.2.0",
"license": "MIT",
"dependencies": {
"@iarna/toml": "2.2.5",
@@ -15,6 +15,7 @@
},
"bin": {
"ecc": "scripts/ecc.js",
"ecc-universal": "scripts/ecc.js",
"ecc-control-pane": "scripts/control-pane.js",
"ecc-install": "scripts/install-apply.js",
"ecc-memory-mcp": "scripts/memory-mcp.mjs",
+6 -2
View File
@@ -1,6 +1,6 @@
{
"name": "ecc-universal",
"version": "2.1.0",
"version": "2.2.0",
"description": "Harness-native agent operating system for Codex, OpenCode, Cursor, Gemini, Claude Code, and terminal workflows - skills, hooks, rules, MCP conventions, and operator control-plane patterns",
"publishConfig": {
"access": "public"
@@ -114,6 +114,7 @@
"scripts/skills-health.js",
"scripts/hooks/",
"scripts/install-apply.js",
"scripts/install-guided.js",
"scripts/install-plan.js",
"scripts/ito.js",
"scripts/lib/",
@@ -124,6 +125,8 @@
"scripts/orchestrate-codex-worker.sh",
"scripts/orchestrate-worktrees.js",
"scripts/repair.js",
"scripts/setup.js",
"scripts/welcome.js",
"scripts/session-inspect.js",
"scripts/sessions-cli.js",
"scripts/setup-package-manager.js",
@@ -424,7 +427,8 @@
"ecc-control-pane": "scripts/control-pane.js",
"ecc-install": "scripts/install-apply.js",
"ecc-memory-mcp": "scripts/memory-mcp.mjs",
"ecc-plan-canvas": "scripts/plan-canvas.js"
"ecc-plan-canvas": "scripts/plan-canvas.js",
"ecc-universal": "scripts/ecc.js"
},
"scripts": {
"welcome": "echo '\\n ecc-universal installed!\\n Run: ecc typescript\\n Compat: ecc-install typescript\\n Docs: https://github.com/affaan-m/ECC\\n Run or self-host any open-source model.\\n Compute: Itô is the preferred compute sponsor — https://compute.itomarkets.com\\n Any GPU provider works. This sponsorship link is passive: it does not invoke an RFQ, reserve capacity, provision compute, or configure serving.\\n Separately, the opt-in ecc ito find bridge invokes the explicitly configured canonical Itô CLI and submits a live authenticated RFQ; it does not reserve capacity.\\n Managed inference through Itô is not live yet.\\n'",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ecc",
"version": "2.1.0",
"version": "2.2.0",
"description": "Harness-native ECC workflows for Codex: shared skills, production-ready MCP configs, and selective-install-aligned conventions for TDD, security scanning, code review, and autonomous development.",
"author": {
"name": "Affaan Mustafa",
+13 -14
View File
@@ -1,10 +1,11 @@
# plugins/ecc — Codex Repo-Marketplace Plugin Target
# plugins/ecc — Legacy Codex Thin-Plugin Artifact
This directory is the plugin folder that `.agents/plugins/marketplace.json`
points at. Codex does not discover plugins whose local marketplace
`source.path` is the marketplace root itself (`./`), so the marketplace entry
must target a concrete plugin subdirectory — verified against Codex CLI
0.137.0 and the official plugin docs (`$REPO_ROOT/plugins/<name>`).
This directory is retained as a legacy compatibility artifact. The current
`.agents/plugins/marketplace.json` points at the self-contained repository root,
which Codex 0.146.0 accepts and copies with all referenced runtime content.
Do not point the active marketplace back at this thin directory: its
parent-relative references are valid in a checkout but escape the isolated
plugin cache after installation.
## Single source of truth
@@ -26,12 +27,10 @@ bumps both.
## Current Codex plugin-mode status
With this layout, `codex plugin marketplace add affaan-m/ECC` discovers and
installs `ecc@ecc`. Runtime skill loading from repo marketplaces is still
unreliable upstream — Codex copies only the plugin folder into its install
cache, and local/personal marketplace plugins are not always exposed at
runtime (see [openai/codex#26037](https://github.com/openai/codex/issues/26037)
and [affaan-m/ECC#2128](https://github.com/affaan-m/ECC/issues/2128)).
The native marketplace now installs from the repository root. A fresh Codex
0.146.0 cache contains the configure skill, shared skills, MCP configuration,
hooks, scripts, and assets, and an authenticated session loads the
`configure-ecc` skill without hook failures.
After install, `codex plugin list` is not enough to prove the runtime can load
the referenced skills and assets. From an ECC checkout, run:
@@ -44,8 +43,8 @@ The check inspects the installed cache under `CODEX_HOME` (or `~/.codex`) and
fails if `.codex-plugin/plugin.json` points at files that were not copied into
that cache entry.
Until the upstream discovery issues settle, the supported Codex path is the
manual sync flow documented in the README:
The manual sync flow remains available only as a separate legacy compatibility
path when copied/merged home configuration is explicitly desired:
```bash
npm install && bash scripts/sync-ecc-to-codex.sh
+4
View File
@@ -202,6 +202,10 @@
},
"scaffoldOnly": {
"type": "boolean"
},
"contentSha256": {
"type": "string",
"pattern": "^[a-fA-F0-9]{64}$"
}
}
}
+5 -5
View File
@@ -240,14 +240,14 @@ function parseArgs(argv) {
function commandFor(kind, id, target) {
if (kind === 'profile') {
return `npx ecc install --profile ${id} --target ${target}`;
return `npx ecc-universal install --profile ${id} --target ${target}`;
}
return `npx ecc install --profile minimal --target ${target} --with ${id}`;
return `npx ecc-universal install --profile minimal --target ${target} --with ${id}`;
}
function planCommandFor(componentId, target) {
return `npx ecc plan --profile minimal --target ${target} --with ${componentId}`;
return `npx ecc-universal plan --profile minimal --target ${target} --with ${componentId}`;
}
function buildSearchCorpus(parts) {
@@ -421,7 +421,7 @@ function buildConsultation(options) {
`Install it: ${matches[0].installCommand}`,
]
: [
'Run `npx ecc catalog components` to browse all components.',
'Run `npx ecc-universal catalog components` to browse all components.',
'Try a more specific query such as "security review", "Next.js", or "operator workflows".',
],
};
@@ -437,7 +437,7 @@ function formatText(payload) {
if (payload.matches.length === 0) {
lines.push('No strong component matches found.');
lines.push('Try: npx ecc catalog components');
lines.push('Try: npx ecc-universal catalog components');
} else {
lines.push('Recommended components:');
payload.matches.forEach((match, index) => {
+19 -4
View File
@@ -7,9 +7,17 @@ const { getComputeSponsorCopy } = require('./lib/compute-sponsor');
const { createSafeItoInvocationEnvironment, getInvocationCommand } = require('./lib/ito-environment');
const COMMANDS = {
setup: {
script: 'setup.js',
description: 'Install or update the Claude plugin with guided scope and hook choices',
},
welcome: {
script: 'welcome.js',
description: 'Show the ECC welcome artwork and community links',
},
install: {
script: 'install-apply.js',
description: 'Install ECC content into a supported target',
description: 'Install ECC content, including the guided multi-harness wizard',
},
plan: {
script: 'install-plan.js',
@@ -94,6 +102,8 @@ const COMMANDS = {
};
const PRIMARY_COMMANDS = [
'setup',
'welcome',
'install',
'plan',
'catalog',
@@ -140,6 +150,11 @@ Compute:
${getComputeSponsorCopy()}
Examples:
ecc setup
ecc setup --mode claude-plugin --scope user --hooks standard --yes
ecc welcome
ecc install --guided
ecc install --guided --harness claude --harness codex --harness kimi
ecc typescript
ecc install --profile developer --target claude
ecc plan --profile core --target cursor
@@ -255,11 +270,11 @@ function runCommand(commandName, args) {
}),
}
: process.env,
stdio: isItoLogin
stdio: isItoLogin || commandName === 'setup' || commandName === 'install'
? 'inherit'
: commandName === 'memory'
? ['inherit', 'pipe', 'pipe']
: ['pipe', 'pipe', 'pipe'],
? ['inherit', 'pipe', 'pipe']
: ['pipe', 'pipe', 'pipe'],
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024,
}
+5 -12
View File
@@ -8,7 +8,7 @@
const path = require('path');
const { StringDecoder } = require('string_decoder');
const { VALID_PROFILES, normalizeId, parseProfiles } = require('../lib/hook-flags');
const { isHookEnabled } = require('../lib/hook-flags');
const { runPostBash } = require('./bash-hook-dispatcher');
const { run: runQualityGate } = require('./quality-gate');
const { run: runDesignQualityCheck } = require('./design-quality-check');
@@ -64,17 +64,10 @@ function matchesTool(matcher, toolName) {
}
function isEnabled(hook, env) {
const disabled = new Set(
String(env.ECC_DISABLED_HOOKS || '')
.split(',')
.map(normalizeId)
.filter(Boolean)
);
const requestedProfile = String(env.ECC_HOOK_PROFILE || 'standard')
.trim()
.toLowerCase();
const profile = VALID_PROFILES.has(requestedProfile) ? requestedProfile : 'standard';
return !disabled.has(normalizeId(hook.id)) && parseProfiles(hook.profiles).includes(profile);
return isHookEnabled(hook.id, {
env,
profiles: hook.profiles,
});
}
function extractToolName(raw) {
+25 -2
View File
@@ -18,6 +18,7 @@ const {
parseInstallArgs,
} = require('./lib/install/request');
const { getComputeSponsorCopy } = require('./lib/compute-sponsor');
const { stripAnsi } = require('./lib/utils');
function getHelpText() {
const languages = listLegacyCompatibilityLanguages();
@@ -44,7 +45,7 @@ Targets:
qwen - Install commands, agents, skills, rules, and Qwen config into ~/.qwen/
zed - Install project settings, commands, agents, skills, and flattened rules into ./.zed/
hermes - Install shared rules/skills/commands into ~/.hermes/
kimi - Install shared rules/skills/commands into ./.kimi/
kimi - Install Kimi Code project instructions, skills, and MCP config into ./.kimi-code/ (ECC hooks not configured)
openclaw - Install shared rules/skills/commands into ~/.openclaw/
Options:
@@ -188,4 +189,26 @@ function main() {
}
}
main();
function sanitizeTerminalText(value) {
return stripAnsi(String(value || '')).replace(/[^\x20-\x7E]/g, '?');
}
function runGuidedMain(guidedArgs) {
Promise.resolve()
.then(() => require('./install-guided').main(guidedArgs))
.then(exitCode => {
process.exitCode = exitCode;
})
.catch(error => {
process.stderr.write(`Error: ${sanitizeTerminalText(error?.message)}\n`);
process.exitCode = 1;
});
}
const cliArgs = process.argv.slice(2);
if (cliArgs.includes('--guided')) {
const guidedArgs = cliArgs.filter(argument => argument !== '--guided');
runGuidedMain(guidedArgs);
} else {
main();
}
+338
View File
@@ -0,0 +1,338 @@
#!/usr/bin/env node
'use strict';
const readline = require('readline/promises');
const {
getHarnessCapability,
listGuidedHarnesses,
normalizeHarnessSelection,
} = require('./lib/harness-capabilities');
const {
VALID_CLAUDE_HOOKS,
VALID_CLAUDE_SCOPES,
VALID_PROFILES,
applyMultiHarnessPlan,
createMultiHarnessPlan,
normalizeGuidedInstallRequest,
} = require('./lib/multi-harness-setup');
const { startTerminalSpinner } = require('./lib/terminal-spinner');
const { showTerminalWelcome } = require('./lib/terminal-welcome');
const { stripAnsi } = require('./lib/utils');
const ADVANCED_HARNESSES = 'Cursor, Antigravity, Gemini CLI, OpenCode, CodeBuddy, JoyCode, Qwen Code, Zed, Hermes, and OpenClaw';
function showHelp(output = process.stdout) {
output.write(`
ECC guided multi-harness install
Usage:
ecc install --guided
ecc install --guided --harness claude --harness codex --harness kimi [options]
Guided harnesses:
claude Native Claude Code plugin; choose user, project, or local scope and an ECC hook profile.
codex Native Codex plugin and Codex-owned hook review/trust.
kimi Managed project install under ./.kimi-code; ECC hooks are not configured.
Options:
--harness <id[,id...]> Repeatable; accepts Claude, Codex, Kimi, or all
--all-harnesses Select all three guided harnesses
--claude-scope <user|project|local>
--claude-hooks <off|minimal|standard|strict>
--profile <minimal|core|developer|security|research|full>
Kimi managed-project content profile
--yes, -y Apply without confirmation
--dry-run Preflight and preview without changing files
--json Emit machine-readable output
--help, -h Show this help
Advanced managed adapters remain available through explicit ecc install --target commands:
${ADVANCED_HARNESSES}
This command configures ECC. It does not install or authenticate provider CLIs.
`);
}
function parseArgs(argv) {
let options = {
allHarnesses: false,
claudeHooks: undefined,
claudeScope: undefined,
dryRun: false,
harnesses: [],
help: false,
json: false,
profile: undefined,
yes: false,
};
const valueFlags = new Map([
['--harness', 'harnesses'],
['--claude-scope', 'claudeScope'],
['--claude-hooks', 'claudeHooks'],
['--profile', 'profile'],
]);
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (valueFlags.has(argument)) {
const value = argv[index + 1];
if (!value || value.startsWith('--')) {
throw new Error(`Missing value for ${argument}`);
}
if (value.length > 256) {
throw new Error(`Value for ${argument} is too long.`);
}
const key = valueFlags.get(argument);
options = key === 'harnesses'
? { ...options, harnesses: [...options.harnesses, value] }
: { ...options, [key]: value };
index += 1;
} else if (argument === '--all-harnesses') {
options = { ...options, allHarnesses: true };
} else if (argument === '--yes' || argument === '-y') {
options = { ...options, yes: true };
} else if (argument === '--dry-run') {
options = { ...options, dryRun: true };
} else if (argument === '--json') {
options = { ...options, json: true };
} else if (argument === '--help' || argument === '-h') {
options = { ...options, help: true };
} else {
throw new Error('Unknown argument. Run guided install with --help to see valid options.');
}
}
if (options.allHarnesses && options.harnesses.length > 0) {
throw new Error('--all-harnesses and --harness are mutually exclusive.');
}
return options;
}
function choicesText(values) {
return values.join('|');
}
async function askChoice(terminal, output, prompt, values, defaultValue) {
output.write(`\n${prompt}\n`);
values.forEach((value, index) => output.write(` ${index + 1}. ${value}\n`));
while (true) {
const question = defaultValue
? `Choose [Recommended: ${defaultValue}] (one option only): `
: 'Choose one option: ';
const answer = (await terminal.question(question)).trim().toLowerCase();
if (!answer && defaultValue) return defaultValue;
const numeric = /^\d+$/.test(answer) ? values[Number(answer) - 1] : undefined;
const selected = numeric || values.find(value => value === answer);
if (selected) return selected;
output.write(`Please choose ${choicesText(values)}.\n`);
}
}
async function askHarnesses(terminal, output) {
const guided = listGuidedHarnesses();
output.write('\nWhich coding agents should ECC configure?\n');
guided.forEach((harness, index) => {
output.write(` ${index + 1}. ${harness.label}${harness.destination}\n`);
});
output.write(' all. All three guided harnesses\n');
output.write(`\nAdvanced adapters (use ecc install --target): ${ADVANCED_HARNESSES}.\n\n`);
while (true) {
const answer = await terminal.question('Choose one or more (for example 1,3 or all): ');
if (answer.length > 1024) {
output.write('Please choose Claude, Codex, Kimi, or all.\n');
continue;
}
try {
return normalizeHarnessSelection(answer);
} catch (_error) {
output.write('Please choose Claude, Codex, Kimi, or all.\n');
}
}
}
async function collectInteractiveOptions(options, dependencies = {}) {
const terminal = dependencies.terminal;
const output = dependencies.output || process.stdout;
let harnesses = options.allHarnesses ? ['all'] : options.harnesses;
if (harnesses.length === 0) harnesses = await askHarnesses(terminal, output);
const normalizedHarnesses = normalizeHarnessSelection(harnesses);
const includesClaude = normalizedHarnesses.includes('claude');
const includesKimi = normalizedHarnesses.includes('kimi');
const claudeScope = includesClaude && !options.claudeScope
? await askChoice(terminal, output, 'Where should Claude enable ecc@ecc?', [...VALID_CLAUDE_SCOPES], 'user')
: options.claudeScope;
const claudeHooks = includesClaude && !options.claudeHooks
? await askChoice(terminal, output, 'How should ECC hooks run in Claude?', [...VALID_CLAUDE_HOOKS], 'standard')
: options.claudeHooks;
const profile = includesKimi && !options.profile
? await askChoice(terminal, output, 'Which ECC content profile should Kimi receive?', [...VALID_PROFILES], 'core')
: options.profile;
return {
...options,
harnesses: normalizedHarnesses,
claudeScope,
claudeHooks,
profile,
};
}
function selectedHarnessIds(options) {
if (options.allHarnesses) return normalizeHarnessSelection(['all']);
if (options.harnesses.length === 0) return [];
return normalizeHarnessSelection(options.harnesses);
}
function validateExecutionMode(options, interactive) {
const harnesses = selectedHarnessIds(options);
if (!interactive && harnesses.length === 0) {
throw new Error('Non-interactive guided install requires at least one --harness.');
}
const requiresExplicit = !interactive || options.json;
if (requiresExplicit && harnesses.includes('claude') && (!options.claudeScope || !options.claudeHooks)) {
throw new Error('Claude requires explicit --claude-scope and --claude-hooks choices in this mode.');
}
if (requiresExplicit && harnesses.includes('kimi') && !options.profile) {
throw new Error('Kimi requires an explicit --profile choice in this mode.');
}
if ((!interactive || options.json) && !options.yes && !options.dryRun) {
throw new Error('Non-interactive and JSON mutations require --yes.');
}
}
function printPlan(plan, output) {
output.write('\nECC guided install preview\n\n');
output.write('Harness Channel Destination\n');
for (const entry of plan.harnesses) {
const harness = getHarnessCapability(entry.id);
output.write(`${harness.label.padEnd(13)} ${entry.channel.padEnd(17)} ${harness.destination}\n`);
}
if (plan.request.harnesses.includes('kimi')) {
output.write('\nKimi note: ECC hooks are not configured; model, provider, and authentication settings are unchanged.\n');
}
}
async function confirmPlan(terminal, output) {
output.write('\n');
const answer = await terminal.question('Apply ECC to these harnesses? [y/N]: ');
return /^y(es)?$/i.test(answer.trim());
}
function sanitizeTerminalText(value) {
return stripAnsi(String(value || '')).replace(/[^\x20-\x7E]/g, '?');
}
function buildRetryArguments(plan, retryHarnesses) {
const harnesses = [...retryHarnesses];
const harnessArguments = harnesses.flatMap(id => ['--harness', id]);
const claudeArguments = harnesses.includes('claude')
? ['--claude-scope', plan.request.claudeScope, '--claude-hooks', plan.request.claudeHooks]
: [];
const kimiArguments = harnesses.includes('kimi')
? ['--profile', plan.request.profile]
: [];
return [...harnessArguments, ...claudeArguments, ...kimiArguments].join(' ');
}
async function main(argv = process.argv.slice(2), injected = {}) {
const output = injected.output || process.stdout;
const errorOutput = injected.errorOutput || process.stderr;
const interactive = injected.interactive !== undefined
? injected.interactive
: Boolean(process.stdin.isTTY && output.isTTY);
const createPlan = injected.createPlan || createMultiHarnessPlan;
const applyPlan = injected.applyPlan || applyMultiHarnessPlan;
const renderWelcome = injected.showWelcome || showTerminalWelcome;
const makeSpinner = injected.startSpinner || startTerminalSpinner;
let terminal = injected.terminal;
let ownsTerminal = false;
try {
let options = parseArgs(argv);
if (options.help) {
showHelp(output);
return 0;
}
validateExecutionMode(options, interactive);
const needsChoices = selectedHarnessIds(options).length === 0
|| (selectedHarnessIds(options).includes('claude') && (!options.claudeScope || !options.claudeHooks))
|| (selectedHarnessIds(options).includes('kimi') && !options.profile);
if (interactive && needsChoices) {
if (!terminal) {
terminal = readline.createInterface({ input: process.stdin, output });
ownsTerminal = true;
}
options = await collectInteractiveOptions(options, { output, terminal });
}
const request = normalizeGuidedInstallRequest({
...options,
harnesses: options.allHarnesses ? ['all'] : options.harnesses,
});
const plan = await createPlan(request);
if (options.json && options.dryRun) {
output.write(`${JSON.stringify({ dryRun: true, plan }, null, 2)}\n`);
return 0;
}
if (!options.json) printPlan(plan, output);
if (options.dryRun) {
output.write('\nDry run complete. No changes were made.\n');
return 0;
}
if (!options.yes) {
if (!terminal) {
terminal = readline.createInterface({ input: process.stdin, output });
ownsTerminal = true;
}
if (!await confirmPlan(terminal, output)) {
output.write('\nECC install cancelled. No changes were made.\n');
return 0;
}
}
const spinner = interactive && !options.json
? makeSpinner('Applying ECC to selected harnesses...')
: undefined;
let result;
try {
result = await applyPlan(plan);
} finally {
spinner?.stop();
}
if (options.json) {
output.write(`${JSON.stringify({ dryRun: false, result }, null, 2)}\n`);
} else if (result.status === 'complete') {
output.write(`\nECC configured for ${result.completed.map(item => getHarnessCapability(item.id).label).join(', ')}.\n`);
renderWelcome({ action: 'installed', interactive, json: false, output });
} else {
const retry = buildRetryArguments(plan, result.retryHarnesses);
errorOutput.write(
`ECC stopped at ${sanitizeTerminalText(result.failure.id)}: `
+ `${sanitizeTerminalText(result.failure.message)}\n`
+ `Retry with: ecc-universal install --guided ${retry}\n`
);
}
return result.status === 'complete' ? 0 : 1;
} catch (error) {
const payload = { error: { code: 'GUIDED_INSTALL_FAILED', message: error.message } };
if (argv.includes('--json')) errorOutput.write(`${JSON.stringify(payload, null, 2)}\n`);
else errorOutput.write(`Error: ${sanitizeTerminalText(error.message)}\n`);
return 1;
} finally {
if (ownsTerminal) terminal?.close();
}
}
if (require.main === module) {
main().then(code => {
process.exitCode = code;
});
}
module.exports = {
collectInteractiveOptions,
main,
parseArgs,
printPlan,
showHelp,
validateExecutionMode,
};
+39
View File
@@ -0,0 +1,39 @@
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
function writeFileAtomic(filePath, content, options = {}) {
const resolvedPath = path.resolve(filePath);
const parentDir = path.dirname(resolvedPath);
const tempPath = path.join(
parentDir,
`.${path.basename(resolvedPath)}.${process.pid}.${crypto.randomBytes(8).toString('hex')}.tmp`
);
const mode = options.mode || 0o600;
fs.mkdirSync(parentDir, { recursive: true });
let descriptor;
try {
descriptor = fs.openSync(tempPath, 'wx', mode);
fs.writeFileSync(descriptor, content, { encoding: options.encoding || 'utf8' });
fs.fsyncSync(descriptor);
fs.closeSync(descriptor);
descriptor = undefined;
fs.renameSync(tempPath, resolvedPath);
} catch (error) {
if (descriptor !== undefined) {
fs.closeSync(descriptor);
}
fs.rmSync(tempPath, { force: true });
throw error;
}
return resolvedPath;
}
module.exports = {
writeFileAtomic,
};
+652
View File
@@ -0,0 +1,652 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { writeFileAtomic } = require('./atomic-write');
const { normalizeGitHubGitOrigin } = require('./github-origin');
const {
CURRENT_PLUGIN_ID,
LEGACY_PLUGIN_IDS,
findManagedClaudeInstalls,
findManualClaudePlugin,
resolveClaudePaths,
} = require('./install/inventory');
const OFFICIAL_MARKETPLACE_NAME = 'ecc';
const OFFICIAL_MARKETPLACE_REPO = 'affaan-m/ecc';
const OFFICIAL_MARKETPLACE_URL = 'https://github.com/affaan-m/ECC';
const PROVIDER_COMMAND_TIMEOUT_MS = 120 * 1000;
const VALID_SCOPES = new Set(['user', 'project', 'local']);
const VALID_HOOK_MODES = new Set(['off', 'minimal', 'standard', 'strict']);
class ClaudeSetupError extends Error {
constructor(code, message, details = {}) {
super(message);
this.name = 'ClaudeSetupError';
this.code = code;
this.phase = details.phase || 'preflight';
this.observedScopes = [...(details.observedScopes || [])];
this.recovery = [...(details.recovery || [])];
}
toJSON() {
return {
error: {
code: this.code,
message: this.message,
phase: this.phase,
observedScopes: [...this.observedScopes],
recovery: [...this.recovery],
},
};
}
}
function fail(code, message, details) {
throw new ClaudeSetupError(code, message, details);
}
function normalizeGitHubRepository(value) {
if (typeof value !== 'string') return null;
const normalized = value.trim().replace(/\.git$/i, '').replace(/\/+$/, '');
const match = normalized.match(/^([^/]+\/[^/]+)$/);
return match ? match[1].toLowerCase() : null;
}
function normalizeMarketplaceRepository(marketplace) {
return marketplace?.source === 'github'
? normalizeGitHubRepository(marketplace.repo)
: normalizeGitHubGitOrigin(marketplace?.url);
}
function isOfficialMarketplace(marketplace) {
if (!marketplace || marketplace.name !== OFFICIAL_MARKETPLACE_NAME) return false;
return normalizeMarketplaceRepository(marketplace) === OFFICIAL_MARKETPLACE_REPO;
}
function parseJsonArray(stdout, label) {
let parsed;
try {
parsed = JSON.parse(String(stdout || ''));
} catch (error) {
fail(
`INVALID_${label.toUpperCase()}_INVENTORY`,
`Claude ${label} inventory returned invalid JSON: ${error.message}`
);
}
if (!Array.isArray(parsed)) {
fail(
`INVALID_${label.toUpperCase()}_INVENTORY`,
`Claude ${label} inventory is invalid: expected a JSON array`
);
}
return parsed;
}
function parsePluginList(stdout) {
const plugins = parseJsonArray(stdout, 'plugin');
for (const plugin of plugins) {
const isRelevant = plugin && (
plugin.id === CURRENT_PLUGIN_ID
|| String(plugin.id || '').startsWith('ecc@')
|| LEGACY_PLUGIN_IDS.has(plugin.id)
|| String(plugin.id || '').startsWith('everything-claude-code@')
);
if (!isRelevant) continue;
if (
typeof plugin.id !== 'string'
|| !VALID_SCOPES.has(plugin.scope)
|| typeof plugin.enabled !== 'boolean'
) {
fail(
'INVALID_PLUGIN_INVENTORY',
'Claude plugin inventory contains an invalid ECC plugin entry'
);
}
}
return plugins;
}
function parseMarketplaceList(stdout) {
const marketplaces = parseJsonArray(stdout, 'marketplace');
for (const marketplace of marketplaces) {
if (!marketplace || marketplace.name !== OFFICIAL_MARKETPLACE_NAME) continue;
if (
typeof marketplace.name !== 'string'
|| typeof marketplace.source !== 'string'
|| !['github', 'git'].includes(marketplace.source)
|| !normalizeMarketplaceRepository(marketplace)
) {
fail(
'INVALID_MARKETPLACE_INVENTORY',
'Claude marketplace inventory contains an invalid `ecc` entry'
);
}
}
return marketplaces;
}
const UNSAFE_WINDOWS_SHELL_CHARS = /[\r\n&|<>^%!]/;
function quoteWindowsCommandToken(value) {
const token = String(value);
if (UNSAFE_WINDOWS_SHELL_CHARS.test(token)) {
throw new Error('Claude Code command contains characters that are unsafe for cmd.exe');
}
if (token === '') return '""';
if (!/[\s"]/.test(token)) return token;
return `"${token.replace(/"/g, '""')}"`;
}
function buildWindowsCommandLine(command, args) {
return [command, ...args].map(quoteWindowsCommandToken).join(' ');
}
function resolveWindowsCmdShim(command, env) {
if (typeof command !== 'string' || command.length === 0) return null;
if (/\.(cmd|bat)$/i.test(command)) return command;
if (path.extname(command)) return null;
const isPathLike = path.isAbsolute(command)
|| command.includes('/')
|| command.includes('\\');
if (isPathLike) {
const candidate = `${command}.cmd`;
return fs.existsSync(candidate) ? candidate : null;
}
const lookup = spawnSync('where.exe', [`${command}.cmd`], {
env,
encoding: 'utf8',
windowsHide: true,
});
if (lookup.error || lookup.status !== 0) return null;
return String(lookup.stdout || '')
.split(/\r?\n/)
.map(line => line.trim())
.find(Boolean) || null;
}
function runClaude(args, options = {}, dependencies = {}) {
const command = options.command || 'claude';
const spawn = dependencies.spawnSync || spawnSync;
const timeoutMs = options.timeoutMs ?? PROVIDER_COMMAND_TIMEOUT_MS;
const spawnOptions = {
cwd: options.cwd || process.cwd(),
env: options.env || process.env,
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024,
killSignal: 'SIGKILL',
timeout: timeoutMs,
windowsHide: true,
};
let result = spawn(command, args, spawnOptions);
if (process.platform === 'win32' && result.error) {
const shim = resolveWindowsCmdShim(command, spawnOptions.env);
if (shim) {
let commandLine;
try {
commandLine = buildWindowsCommandLine(shim, args);
} catch (error) {
fail(
'CLAUDE_COMMAND_FAILED',
`Could not run Claude Code: ${error.message}`,
{ phase: options.phase || 'provider' }
);
}
result = spawn(commandLine, {
...spawnOptions,
shell: true,
});
}
}
const timedOut = (
result.error?.code === 'ETIMEDOUT'
|| (result.error?.killed === true && result.error?.signal === spawnOptions.killSignal)
);
if (timedOut) {
fail(
'CLAUDE_COMMAND_FAILED',
`Claude Code command timed out after ${timeoutMs} ms`,
{ phase: options.phase || 'provider' }
);
}
if (result.error) {
if (result.error.code === 'ENOENT') {
fail(
'CLAUDE_NOT_FOUND',
'Claude Code is not installed or `claude` is not on PATH. Install Claude Code, then rerun ECC setup.',
{ phase: options.phase || 'inventory' }
);
}
fail(
'CLAUDE_COMMAND_FAILED',
`Could not run Claude Code: ${result.error.message}`,
{ phase: options.phase || 'provider' }
);
}
if (result.status !== 0) {
const detail = String(result.stderr || result.stdout || '').trim();
fail(
'CLAUDE_COMMAND_FAILED',
`Claude Code command failed${detail ? `: ${detail}` : ''}`,
{ phase: options.phase || 'provider' }
);
}
return result;
}
function readSettings(settingsPath) {
if (!fs.existsSync(settingsPath)) return {};
let settings;
try {
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
} catch (error) {
fail(
'INVALID_CLAUDE_SETTINGS',
`Claude user settings are invalid at ${settingsPath}: ${error.message}`,
{ phase: 'preflight' }
);
}
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
fail(
'INVALID_CLAUDE_SETTINGS',
`Claude user settings are invalid at ${settingsPath}: expected a JSON object`,
{ phase: 'preflight' }
);
}
const pluginConfigs = settings.pluginConfigs;
if (pluginConfigs !== undefined && (
!pluginConfigs
|| typeof pluginConfigs !== 'object'
|| Array.isArray(pluginConfigs)
)) {
fail(
'INVALID_CLAUDE_SETTINGS',
`Claude user settings are invalid at ${settingsPath}: pluginConfigs must be an object`,
{ phase: 'preflight' }
);
}
const eccConfig = pluginConfigs?.[CURRENT_PLUGIN_ID];
if (eccConfig !== undefined && (
!eccConfig
|| typeof eccConfig !== 'object'
|| Array.isArray(eccConfig)
)) {
fail(
'INVALID_CLAUDE_SETTINGS',
`Claude user settings are invalid at ${settingsPath}: ${CURRENT_PLUGIN_ID} config must be an object`,
{ phase: 'preflight' }
);
}
if (eccConfig?.options !== undefined && (
!eccConfig.options
|| typeof eccConfig.options !== 'object'
|| Array.isArray(eccConfig.options)
)) {
fail(
'INVALID_CLAUDE_SETTINGS',
`Claude user settings are invalid at ${settingsPath}: ${CURRENT_PLUGIN_ID} options must be an object`,
{ phase: 'preflight' }
);
}
return settings;
}
function hookOptions(hooks) {
return {
hooks_enabled: hooks !== 'off',
hook_profile: hooks === 'off' ? 'standard' : hooks,
};
}
function readStoredHookOptions(settings) {
const options = settings.pluginConfigs?.[CURRENT_PLUGIN_ID]?.options || {};
return {
hooks_enabled: options.hooks_enabled !== false,
hook_profile: VALID_HOOK_MODES.has(options.hook_profile)
&& options.hook_profile !== 'off'
? options.hook_profile
: 'standard',
};
}
function deriveHookMode(settings) {
const options = readStoredHookOptions(settings);
return options.hooks_enabled ? options.hook_profile : 'off';
}
function writeClaudePluginOptions(settingsPath, hooks) {
const settings = readSettings(settingsPath);
const pluginConfigs = settings.pluginConfigs || {};
const eccConfig = pluginConfigs[CURRENT_PLUGIN_ID] || {};
const options = eccConfig.options || {};
const nextSettings = {
...settings,
pluginConfigs: {
...pluginConfigs,
[CURRENT_PLUGIN_ID]: {
...eccConfig,
options: {
...options,
...hookOptions(hooks),
},
},
},
};
writeFileAtomic(settingsPath, `${JSON.stringify(nextSettings, null, 2)}\n`);
return settingsPath;
}
function currentEccPlugins(plugins) {
return plugins.filter(plugin => plugin?.id === CURRENT_PLUGIN_ID);
}
function assertNoConflictingEccPlugins(plugins) {
const legacy = plugins.find(plugin => (
LEGACY_PLUGIN_IDS.has(plugin?.id)
|| String(plugin?.id || '').startsWith('everything-claude-code@')
));
if (legacy) {
fail(
'LEGACY_PLUGIN_INSTALLED',
`Legacy plugin ${legacy.id} is installed. Uninstall it before setting up ${CURRENT_PLUGIN_ID}.`,
{
observedScopes: [legacy.scope],
recovery: [`claude plugin uninstall ${legacy.id} --scope ${legacy.scope} --keep-data`],
}
);
}
const conflictingEcc = plugins.find(plugin => (
typeof plugin?.id === 'string'
&& plugin.id.startsWith('ecc@')
&& plugin.id !== CURRENT_PLUGIN_ID
));
if (conflictingEcc) {
fail(
'DUPLICATE_ECC_PLUGIN',
`${conflictingEcc.id} is already installed and would duplicate ECC surfaces. Uninstall it before setting up ${CURRENT_PLUGIN_ID}.`,
{
observedScopes: [conflictingEcc.scope],
recovery: [
`claude plugin uninstall ${conflictingEcc.id} --scope ${conflictingEcc.scope} --keep-data`,
],
}
);
}
}
function inspectPluginInventory(plugins, requestedScope) {
assertNoConflictingEccPlugins(plugins);
const installed = currentEccPlugins(plugins);
const observedScopes = installed.map(plugin => plugin.scope);
if (installed.length > 1 || new Set(observedScopes).size !== observedScopes.length) {
fail(
'MULTIPLE_PLUGIN_SCOPES',
`${CURRENT_PLUGIN_ID} is installed in multiple scopes. Resolve the duplicate scopes before setup.`,
{ observedScopes }
);
}
if (!requestedScope && installed.length === 0) {
fail(
'SCOPE_REQUIRED',
'A fresh install requires --scope user, project, or local.'
);
}
const scope = requestedScope || installed[0].scope;
if (!VALID_SCOPES.has(scope)) {
fail('INVALID_SCOPE', `Invalid plugin scope: ${scope}`);
}
if (installed.length === 1 && installed[0].scope !== scope) {
fail(
'SCOPE_MOVE_REQUIRED',
`${CURRENT_PLUGIN_ID} is already installed at ${installed[0].scope} scope. Use the scope migration workflow to move it to ${scope}.`,
{
observedScopes,
recovery: [
`ecc setup --mode claude-plugin --scope ${scope} --move-scope --yes`,
],
}
);
}
return {
installed: installed[0] || null,
observedScopes,
scope,
};
}
function assertSafeLocalInventory(options) {
const manual = findManualClaudePlugin(options);
if (manual) {
fail(
'MANUAL_PLUGIN_INSTALL',
`A manual ECC plugin layout exists at ${manual.manifestPath}. Remove or migrate the manual install before setup.`
);
}
let managedInstalls;
try {
managedInstalls = findManagedClaudeInstalls(options);
} catch (error) {
fail('INVALID_MANAGED_STATE', error.message);
}
const overlap = managedInstalls.find(install => install.overlapsPlugin);
if (overlap) {
fail(
'MANAGED_INSTALL_OVERLAP',
`Managed ECC content at ${overlap.statePath} overlaps the Claude plugin. Remove that managed overlap before setup.`
);
}
return managedInstalls;
}
function ensureOfficialMarketplace(options) {
const run = options.run || runClaude;
const existing = options.marketplaces.find(entry => entry?.name === OFFICIAL_MARKETPLACE_NAME);
if (existing && !isOfficialMarketplace(existing)) {
fail(
'MARKETPLACE_COLLISION',
'Refusing the `ecc` marketplace collision because it is not the official affaan-m/ECC source.'
);
}
if (existing) {
run(
['plugin', 'marketplace', 'update', OFFICIAL_MARKETPLACE_NAME],
{ cwd: options.projectRoot, phase: 'marketplace' }
);
} else {
run(
[
'plugin', 'marketplace', 'add',
OFFICIAL_MARKETPLACE_URL,
'--scope', options.scope,
],
{ cwd: options.projectRoot, phase: 'marketplace' }
);
}
const verified = parseMarketplaceList(
run(
['plugin', 'marketplace', 'list', '--json'],
{ cwd: options.projectRoot, phase: 'marketplace-verification' }
).stdout
).find(entry => entry?.name === OFFICIAL_MARKETPLACE_NAME);
if (!verified || !isOfficialMarketplace(verified)) {
fail(
'MARKETPLACE_VERIFICATION_FAILED',
'Could not verify the official ECC marketplace after the marketplace change.',
{ phase: 'marketplace-verification' }
);
}
return verified;
}
function verifyPluginAtScope(options) {
const run = options.run || runClaude;
const plugins = parsePluginList(
run(
['plugin', 'list', '--json'],
{ cwd: options.projectRoot, phase: options.phase || 'plugin-verification' }
).stdout
);
const installed = currentEccPlugins(plugins);
const valid = (
installed.length === 1
&& installed[0].scope === options.scope
&& installed[0].enabled === true
);
if (!valid) {
fail(
'PLUGIN_VERIFICATION_FAILED',
`Could not verify ${CURRENT_PLUGIN_ID} as enabled only at ${options.scope} scope.`,
{
phase: options.phase || 'plugin-verification',
observedScopes: installed.map(plugin => plugin.scope),
}
);
}
return installed[0];
}
function ensurePluginAtScope(options) {
const run = options.run || runClaude;
const configuredHooks = options.hookConfiguration || hookOptions(options.hooks);
if (options.installed) {
run(
['plugin', 'update', CURRENT_PLUGIN_ID, '--scope', options.scope],
{ cwd: options.projectRoot, phase: 'plugin-update' }
);
return 'updated';
}
run(
[
'plugin', 'install', CURRENT_PLUGIN_ID,
'--scope', options.scope,
'--config', `hooks_enabled=${configuredHooks.hooks_enabled}`,
'--config', `hook_profile=${configuredHooks.hook_profile}`,
],
{ cwd: options.projectRoot, phase: 'plugin-install' }
);
return 'installed';
}
function setupClaudePlugin(options = {}, dependencies = {}) {
const paths = resolveClaudePaths(options);
if (options.hooks !== undefined && !VALID_HOOK_MODES.has(options.hooks)) {
fail('INVALID_HOOK_MODE', `Invalid hook mode: ${options.hooks}`);
}
if (options.scope !== undefined && !VALID_SCOPES.has(options.scope)) {
fail('INVALID_SCOPE', `Invalid plugin scope: ${options.scope}`);
}
const settingsPath = path.join(paths.configDir, 'settings.json');
const initialSettings = readSettings(settingsPath);
assertSafeLocalInventory(paths);
const run = dependencies.runClaude || runClaude;
const plugins = parsePluginList(
run(
['plugin', 'list', '--json'],
{ cwd: paths.projectRoot, phase: 'inventory' }
).stdout
);
const inventory = inspectPluginInventory(plugins, options.scope);
const hooks = options.hooks === undefined && inventory.installed
? deriveHookMode(initialSettings)
: (options.hooks || 'standard');
const marketplaces = parseMarketplaceList(
run(
['plugin', 'marketplace', 'list', '--json'],
{ cwd: paths.projectRoot, phase: 'marketplace-inventory' }
).stdout
);
const namedMarketplace = marketplaces.find(entry => (
entry?.name === OFFICIAL_MARKETPLACE_NAME
));
if (namedMarketplace && !isOfficialMarketplace(namedMarketplace)) {
fail(
'MARKETPLACE_COLLISION',
'Refusing the `ecc` marketplace collision because it is not the official affaan-m/ECC source.'
);
}
if (options.dryRun) {
return {
action: inventory.installed ? 'would-update' : 'would-install',
dryRun: true,
hooks,
marketplaceAction: namedMarketplace ? 'would-update' : 'would-add',
pluginId: CURRENT_PLUGIN_ID,
scope: inventory.scope,
};
}
ensureOfficialMarketplace({
marketplaces,
projectRoot: paths.projectRoot,
run,
scope: inventory.scope,
});
const action = ensurePluginAtScope({
hooks,
installed: inventory.installed,
projectRoot: paths.projectRoot,
run,
scope: inventory.scope,
});
verifyPluginAtScope({
phase: 'plugin-verification',
projectRoot: paths.projectRoot,
run,
scope: inventory.scope,
});
if (options.hooks !== undefined || !inventory.installed) {
writeClaudePluginOptions(settingsPath, hooks);
}
return {
action,
hooks,
pluginId: CURRENT_PLUGIN_ID,
restartRequired: true,
scope: inventory.scope,
settingsPath,
};
}
module.exports = {
ClaudeSetupError,
CURRENT_PLUGIN_ID,
OFFICIAL_MARKETPLACE_NAME,
OFFICIAL_MARKETPLACE_URL,
PROVIDER_COMMAND_TIMEOUT_MS,
VALID_HOOK_MODES,
VALID_SCOPES,
buildWindowsCommandLine,
assertNoConflictingEccPlugins,
assertSafeLocalInventory,
currentEccPlugins,
deriveHookMode,
ensureOfficialMarketplace,
ensurePluginAtScope,
hookOptions,
inspectPluginInventory,
isOfficialMarketplace,
parseMarketplaceList,
parsePluginList,
readStoredHookOptions,
readSettings,
runClaude,
setupClaudePlugin,
verifyPluginAtScope,
writeClaudePluginOptions,
};
+390
View File
@@ -0,0 +1,390 @@
'use strict';
const path = require('path');
const {
ClaudeSetupError,
CURRENT_PLUGIN_ID,
OFFICIAL_MARKETPLACE_URL,
VALID_HOOK_MODES,
VALID_SCOPES,
assertNoConflictingEccPlugins,
assertSafeLocalInventory,
currentEccPlugins,
deriveHookMode,
ensureOfficialMarketplace,
ensurePluginAtScope,
hookOptions,
isOfficialMarketplace,
parseMarketplaceList,
parsePluginList,
readSettings,
readStoredHookOptions,
runClaude,
writeClaudePluginOptions,
} = require('./claude-plugin-setup');
const { resolveClaudePaths } = require('./install/inventory');
function migrationError(code, message, details = {}) {
return new ClaudeSetupError(code, message, details);
}
function recoveryCommands(sourceScope, destinationScope) {
const commands = [];
if (sourceScope) {
commands.push(
`claude plugin uninstall ${CURRENT_PLUGIN_ID} --scope ${sourceScope} --keep-data`
);
}
commands.push(
`ecc setup --mode claude-plugin --scope ${destinationScope} --move-scope --yes`
);
return commands;
}
function readPluginInventory(run, projectRoot, phase) {
return parsePluginList(
run(
['plugin', 'list', '--json'],
{ cwd: projectRoot, phase }
).stdout
);
}
function assertMigrationInventory(plugins, destinationScope) {
assertNoConflictingEccPlugins(plugins);
const installed = currentEccPlugins(plugins);
const observedScopes = installed.map(plugin => plugin.scope);
const uniqueScopes = new Set(observedScopes);
if (installed.length === 0) {
throw migrationError(
'PLUGIN_NOT_INSTALLED',
`${CURRENT_PLUGIN_ID} is not installed, so there is no source scope to migrate.`,
{
observedScopes,
recovery: [
`ecc setup --mode claude-plugin --scope ${destinationScope} --yes`,
],
}
);
}
if (
installed.length > 2
|| uniqueScopes.size !== installed.length
|| (
installed.length === 2
&& !uniqueScopes.has(destinationScope)
)
) {
throw migrationError(
'AMBIGUOUS_PLUGIN_SCOPES',
`Cannot safely migrate ${CURRENT_PLUGIN_ID} from ambiguous scopes: ${observedScopes.join(', ')}.`,
{ observedScopes }
);
}
if (installed.length === 1 && installed[0].scope === destinationScope) {
if (installed[0].enabled !== true) {
throw migrationError(
'DESTINATION_VERIFICATION_FAILED',
`${CURRENT_PLUGIN_ID} exists at ${destinationScope} scope but is not enabled.`,
{
phase: 'destination-verification',
observedScopes,
recovery: recoveryCommands(null, destinationScope),
}
);
}
return {
destination: installed[0],
mode: 'already-migrated',
observedScopes,
sourceScope: null,
};
}
if (installed.length === 1) {
return {
destination: null,
mode: 'migrate',
observedScopes,
sourceScope: installed[0].scope,
};
}
return {
destination: installed.find(plugin => plugin.scope === destinationScope),
mode: 'resume',
observedScopes,
sourceScope: installed.find(plugin => plugin.scope !== destinationScope).scope,
};
}
function validateExpectedScopes(plugins, expectedScopes, options = {}) {
assertNoConflictingEccPlugins(plugins);
const installed = currentEccPlugins(plugins);
const observedScopes = installed.map(plugin => plugin.scope);
const actual = [...observedScopes].sort();
const expected = [...expectedScopes].sort();
const destination = installed.find(plugin => plugin.scope === options.destinationScope);
const matches = (
actual.length === expected.length
&& actual.every((scope, index) => scope === expected[index])
&& destination?.enabled === true
);
if (!matches) {
throw migrationError(
options.code,
options.message,
{
phase: options.phase,
observedScopes,
recovery: options.recovery || [],
}
);
}
return installed;
}
function plannedActions(migration, destinationScope, marketplaceAction, hookConfiguration) {
const actions = [];
if (migration.mode === 'migrate') {
actions.push(marketplaceAction);
actions.push([
'plugin', 'install', CURRENT_PLUGIN_ID,
'--scope', destinationScope,
'--config', `hooks_enabled=${hookConfiguration.hooks_enabled}`,
'--config', `hook_profile=${hookConfiguration.hook_profile}`,
]);
}
actions.push(['plugin', 'list', '--json']);
actions.push(['plugin', 'list', '--json']);
actions.push([
'plugin', 'uninstall', CURRENT_PLUGIN_ID,
'--scope', migration.sourceScope,
'--keep-data',
]);
actions.push(['plugin', 'list', '--json']);
return actions;
}
function verifySourceAndDestination(run, paths, migration, destinationScope, phase) {
const expectedScopes = [migration.sourceScope, destinationScope];
return validateExpectedScopes(
readPluginInventory(run, paths.projectRoot, phase),
expectedScopes,
{
code: phase === 'concurrency-check'
? 'CONCURRENT_SCOPE_CHANGE'
: 'DESTINATION_VERIFICATION_FAILED',
destinationScope,
message: phase === 'concurrency-check'
? 'Claude plugin scopes changed during migration; the source was not removed.'
: `Could not verify ${CURRENT_PLUGIN_ID} at the destination before source cleanup.`,
phase,
recovery: recoveryCommands(null, destinationScope),
}
);
}
function uninstallSource(run, paths, migration, destinationScope) {
const args = [
'plugin', 'uninstall', CURRENT_PLUGIN_ID,
'--scope', migration.sourceScope,
'--keep-data',
];
try {
run(args, { cwd: paths.projectRoot, phase: 'source-uninstall' });
return [];
} catch {
let observedScopes = [migration.sourceScope, destinationScope];
try {
const plugins = readPluginInventory(
run,
paths.projectRoot,
'source-uninstall-verification'
);
assertNoConflictingEccPlugins(plugins);
const installed = currentEccPlugins(plugins);
observedScopes = installed.map(plugin => plugin.scope);
if (
installed.length === 1
&& installed[0].scope === destinationScope
&& installed[0].enabled === true
) {
return ['Claude reported an uninstall error, but destination-only state was verified.'];
}
} catch {
// Preserve the safest known two-scope state in the structured recovery.
}
throw migrationError(
'SOURCE_UNINSTALL_FAILED',
`The destination is installed, but Claude could not remove the ${migration.sourceScope} source scope.`,
{
phase: 'source-uninstall',
observedScopes,
recovery: recoveryCommands(migration.sourceScope, destinationScope),
}
);
}
}
function verifyFinalState(run, paths, destinationScope) {
const plugins = readPluginInventory(run, paths.projectRoot, 'final-verification');
return validateExpectedScopes(plugins, [destinationScope], {
code: 'FINAL_VERIFICATION_FAILED',
destinationScope,
message: `Could not verify destination-only ${CURRENT_PLUGIN_ID} state after source cleanup.`,
phase: 'final-verification',
recovery: recoveryCommands(null, destinationScope),
});
}
function migrateClaudePluginScope(options = {}, dependencies = {}) {
if (!VALID_SCOPES.has(options.scope)) {
throw migrationError(
'INVALID_SCOPE',
'Scope migration requires --scope user, project, or local.'
);
}
if (options.hooks !== undefined && !VALID_HOOK_MODES.has(options.hooks)) {
throw migrationError('INVALID_HOOK_MODE', `Invalid hook mode: ${options.hooks}`);
}
const paths = resolveClaudePaths(options);
const settingsPath = path.join(paths.configDir, 'settings.json');
const settings = readSettings(settingsPath);
assertSafeLocalInventory(paths);
const run = dependencies.runClaude || runClaude;
const plugins = readPluginInventory(run, paths.projectRoot, 'inventory');
const migration = assertMigrationInventory(plugins, options.scope);
const hooks = options.hooks === undefined
? deriveHookMode(settings)
: options.hooks;
const hookConfiguration = options.hooks === undefined
? readStoredHookOptions(settings)
: hookOptions(options.hooks);
const marketplaces = parseMarketplaceList(
run(
['plugin', 'marketplace', 'list', '--json'],
{ cwd: paths.projectRoot, phase: 'marketplace-inventory' }
).stdout
);
const namedMarketplace = marketplaces.find(entry => entry?.name === 'ecc');
if (namedMarketplace && !isOfficialMarketplace(namedMarketplace)) {
throw migrationError(
'MARKETPLACE_COLLISION',
'Refusing the `ecc` marketplace collision because it is not the official affaan-m/ECC source.',
{
phase: 'marketplace-inventory',
observedScopes: migration.observedScopes,
}
);
}
if (migration.mode === 'already-migrated') {
const result = {
action: 'already-migrated',
hooks,
pluginId: CURRENT_PLUGIN_ID,
sourceScope: null,
scope: options.scope,
};
if (options.dryRun) {
return {
...result,
dryRun: true,
preferencesUpdated: false,
plannedActions: options.hooks === undefined ? [] : [{
action: 'write-hook-preferences',
...hookConfiguration,
}],
};
}
if (options.hooks !== undefined) {
writeClaudePluginOptions(settingsPath, options.hooks);
return { ...result, preferencesUpdated: true };
}
return result;
}
let marketplaceAction = null;
if (migration.mode === 'migrate') {
marketplaceAction = namedMarketplace
? ['plugin', 'marketplace', 'update', 'ecc']
: [
'plugin', 'marketplace', 'add',
OFFICIAL_MARKETPLACE_URL,
'--scope', options.scope,
];
}
if (options.dryRun) {
return {
action: migration.mode === 'resume' ? 'would-resume' : 'would-migrate',
dryRun: true,
hooks,
plannedActions: plannedActions(
migration,
options.scope,
marketplaceAction,
hookConfiguration
),
pluginId: CURRENT_PLUGIN_ID,
sourceScope: migration.sourceScope,
scope: options.scope,
};
}
if (migration.mode === 'migrate') {
ensureOfficialMarketplace({
marketplaces,
projectRoot: paths.projectRoot,
run,
scope: options.scope,
});
ensurePluginAtScope({
hookConfiguration,
hooks,
installed: false,
projectRoot: paths.projectRoot,
run,
scope: options.scope,
});
}
verifySourceAndDestination(
run,
paths,
migration,
options.scope,
'destination-verification'
);
verifySourceAndDestination(
run,
paths,
migration,
options.scope,
'concurrency-check'
);
const warnings = uninstallSource(run, paths, migration, options.scope);
verifyFinalState(run, paths, options.scope);
if (options.hooks !== undefined) {
writeClaudePluginOptions(settingsPath, options.hooks);
}
const result = {
action: migration.mode === 'resume' ? 'resumed' : 'migrated',
hooks,
pluginId: CURRENT_PLUGIN_ID,
sourceScope: migration.sourceScope,
scope: options.scope,
};
return warnings.length > 0 ? { ...result, warnings } : result;
}
module.exports = {
migrateClaudePluginScope,
};
+478
View File
@@ -0,0 +1,478 @@
'use strict';
const { execFile: nodeExecFile } = require('child_process');
const path = require('path');
const { normalizeGitHubGitOrigin } = require('./github-origin');
const CODEX_PLUGIN_ID = 'ecc@ecc';
const OFFICIAL_MARKETPLACE_NAME = 'ecc';
const OFFICIAL_MARKETPLACE_REPO = 'affaan-m/ECC';
const NORMALIZED_OFFICIAL_MARKETPLACE_REPO = OFFICIAL_MARKETPLACE_REPO.toLowerCase();
const MAX_OUTPUT_BYTES = 10 * 1024 * 1024;
const PROVIDER_COMMAND_TIMEOUT_MS = 120 * 1000;
class CodexPluginSetupError extends Error {
constructor(code, message, details = {}) {
super(message);
this.name = 'CodexPluginSetupError';
this.code = code;
this.phase = details.phase || 'inventory';
this.argv = [...(details.argv || [])];
}
}
function fail(code, message, details) {
throw new CodexPluginSetupError(code, message, details);
}
function parseJsonObject(stdout, inventoryName, phase = 'inventory') {
let parsed;
try {
parsed = JSON.parse(String(stdout || ''));
} catch (error) {
fail(
`INVALID_${inventoryName.toUpperCase()}_INVENTORY`,
`Codex ${inventoryName} inventory returned invalid JSON: ${error.message}`,
{ phase }
);
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
fail(
`INVALID_${inventoryName.toUpperCase()}_INVENTORY`,
`Codex ${inventoryName} inventory is invalid: expected a JSON object`,
{ phase }
);
}
return parsed;
}
function parseMarketplaceInventory(stdout, phase) {
const inventory = parseJsonObject(stdout, 'marketplace', phase);
if (!Array.isArray(inventory.marketplaces)) {
fail(
'INVALID_MARKETPLACE_INVENTORY',
'Codex marketplace inventory is invalid: expected `marketplaces` to be an array',
{ phase }
);
}
for (const marketplace of inventory.marketplaces) {
if (
!marketplace
|| typeof marketplace.name !== 'string'
|| marketplace.name.length === 0
|| typeof marketplace.root !== 'string'
|| marketplace.root.length === 0
) {
fail(
'INVALID_MARKETPLACE_INVENTORY',
'Codex marketplace inventory contains an invalid marketplace entry',
{ phase }
);
}
}
const eccEntries = inventory.marketplaces.filter(
marketplace => marketplace.name === OFFICIAL_MARKETPLACE_NAME
);
if (eccEntries.length > 1) {
fail(
'INVALID_MARKETPLACE_INVENTORY',
'Codex marketplace inventory contains duplicate `ecc` entries',
{ phase }
);
}
return inventory.marketplaces;
}
function assertPluginEntries(entries, field, phase) {
if (!Array.isArray(entries)) {
fail(
'INVALID_PLUGIN_INVENTORY',
`Codex plugin inventory is invalid: expected \`${field}\` to be an array`,
{ phase }
);
}
for (const plugin of entries) {
if (
!plugin
|| typeof plugin.pluginId !== 'string'
|| plugin.pluginId.length === 0
) {
fail(
'INVALID_PLUGIN_INVENTORY',
`Codex plugin inventory contains an invalid \`${field}\` entry`,
{ phase }
);
}
if (plugin.installed !== undefined && typeof plugin.installed !== 'boolean') {
fail(
'INVALID_PLUGIN_INVENTORY',
`Codex plugin inventory contains an invalid \`${field}\` install state`,
{ phase }
);
}
if (plugin.enabled !== undefined && typeof plugin.enabled !== 'boolean') {
fail(
'INVALID_PLUGIN_INVENTORY',
`Codex plugin inventory contains an invalid \`${field}\` enabled state`,
{ phase }
);
}
}
}
function parsePluginInventory(stdout, phase) {
const inventory = parseJsonObject(stdout, 'plugin', phase);
assertPluginEntries(inventory.installed, 'installed', phase);
assertPluginEntries(inventory.available, 'available', phase);
const eccEntries = inventory.installed.filter(
plugin => plugin.pluginId === CODEX_PLUGIN_ID
);
if (eccEntries.length > 1) {
fail(
'INVALID_PLUGIN_INVENTORY',
`Codex plugin inventory contains duplicate ${CODEX_PLUGIN_ID} entries`,
{ phase }
);
}
return {
installed: [...inventory.installed],
available: [...inventory.available],
};
}
function executeFile(execFile, command, args, options) {
return new Promise((resolve, reject) => {
execFile(command, args, options, (error, stdout, stderr) => {
if (error) {
if (error.stderr === undefined) error.stderr = stderr;
if (error.stdout === undefined) error.stdout = stdout;
reject(error);
return;
}
resolve({ stdout: String(stdout || ''), stderr: String(stderr || '') });
});
});
}
function isCommandTimeout(error, killSignal = 'SIGKILL') {
return error?.code === 'ETIMEDOUT'
|| (error?.killed === true && error?.signal === killSignal);
}
async function runCodexCommand(args, options = {}, dependencies = {}) {
const command = dependencies.command || options.command || 'codex';
const execFile = dependencies.execFile || nodeExecFile;
const argv = [...args];
const timeoutMs = options.timeoutMs ?? PROVIDER_COMMAND_TIMEOUT_MS;
const killSignal = 'SIGKILL';
try {
return await executeFile(execFile, command, argv, {
cwd: options.cwd || process.cwd(),
encoding: 'utf8',
env: options.env || process.env,
maxBuffer: MAX_OUTPUT_BYTES,
killSignal,
shell: false,
timeout: timeoutMs,
windowsHide: true,
});
} catch (error) {
if (isCommandTimeout(error, killSignal)) {
fail(
'CODEX_COMMAND_TIMEOUT',
`Codex command timed out after ${timeoutMs} ms`,
{ argv, phase: options.phase }
);
}
if (error?.code === 'ENOENT') {
fail(
'CODEX_NOT_FOUND',
'Codex CLI is not installed or `codex` is not on PATH. Install Codex, then rerun ECC setup.',
{ argv, phase: options.phase }
);
}
const detail = String(error?.stderr || error?.stdout || error?.message || '').trim();
fail(
'CODEX_COMMAND_FAILED',
`Codex command failed${detail ? `: ${detail}` : ''}`,
{ argv, phase: options.phase }
);
}
}
async function resolveMarketplaceRepository(marketplace, options = {}, dependencies = {}) {
const execFile = dependencies.execFile || nodeExecFile;
const timeoutMs = options.timeoutMs ?? PROVIDER_COMMAND_TIMEOUT_MS;
const killSignal = 'SIGKILL';
let result;
try {
result = await executeFile(
execFile,
dependencies.gitCommand || 'git',
['-C', marketplace.root, 'remote', 'get-url', 'origin'],
{
cwd: options.cwd || process.cwd(),
encoding: 'utf8',
env: options.env || process.env,
maxBuffer: MAX_OUTPUT_BYTES,
killSignal,
shell: false,
timeout: timeoutMs,
windowsHide: true,
}
);
} catch (error) {
if (isCommandTimeout(error, killSignal)) {
fail(
'MARKETPLACE_PROVENANCE_TIMEOUT',
`Git provenance verification timed out after ${timeoutMs} ms`,
{ phase: options.phase || 'marketplace-provenance' }
);
}
const detail = String(error?.stderr || error?.message || '').trim();
fail(
'MARKETPLACE_COLLISION',
`Refusing the existing \`ecc\` marketplace because its Git provenance could not be verified${detail ? `: ${detail}` : ''}.`,
{ phase: options.phase || 'marketplace-provenance' }
);
}
return String(result.stdout || '').trim();
}
async function assertOfficialMarketplace(
marketplace,
options,
dependencies,
phase = 'marketplace-provenance'
) {
if (!marketplace) return;
const resolveRepository = dependencies.resolveMarketplaceRepository
|| (entry => resolveMarketplaceRepository(
entry,
{ ...options, phase },
dependencies
));
let repository;
try {
repository = normalizeGitHubGitOrigin(await resolveRepository(marketplace));
} catch (error) {
if (error instanceof CodexPluginSetupError) throw error;
const detail = String(error?.message || error || '').trim();
fail(
'MARKETPLACE_COLLISION',
`Refusing the existing \`ecc\` marketplace because its provenance could not be verified${detail ? `: ${detail}` : ''}.`,
{ phase }
);
}
if (repository !== NORMALIZED_OFFICIAL_MARKETPLACE_REPO) {
fail(
'MARKETPLACE_COLLISION',
'Refusing the existing `ecc` marketplace because it is not the official affaan-m/ECC source.',
{ phase }
);
}
}
function normalizeMarketplaceRoot(value) {
if (typeof value !== 'string' || value.length === 0) return null;
const isWindowsPath = /^[a-z]:[\\/]/i.test(value) || /^\\\\/.test(value);
const normalized = isWindowsPath
? path.win32.normalize(value)
: path.posix.normalize(value);
return isWindowsPath ? normalized.toLowerCase() : normalized;
}
function parseMarketplaceUpgradeResult(stdout, marketplace) {
const phase = 'marketplace-upgrade';
const argv = [
'plugin', 'marketplace', 'upgrade', OFFICIAL_MARKETPLACE_NAME, '--json',
];
let result;
try {
result = JSON.parse(String(stdout || ''));
} catch (error) {
fail(
'INVALID_MARKETPLACE_UPGRADE_RESULT',
`Codex marketplace refresh returned invalid JSON: ${error.message}`,
{ phase, argv }
);
}
const validShape = (
result
&& typeof result === 'object'
&& !Array.isArray(result)
&& Array.isArray(result.selectedMarketplaces)
&& result.selectedMarketplaces.every(name => typeof name === 'string')
&& Array.isArray(result.upgradedRoots)
&& result.upgradedRoots.every(root => typeof root === 'string' && root.length > 0)
&& Array.isArray(result.errors)
);
if (!validShape) {
fail(
'INVALID_MARKETPLACE_UPGRADE_RESULT',
'Codex marketplace refresh returned an invalid result.',
{ phase, argv }
);
}
const expectedRoot = normalizeMarketplaceRoot(marketplace.root);
const upgradedRoot = result.upgradedRoots.length === 1
? normalizeMarketplaceRoot(result.upgradedRoots[0])
: null;
if (
result.errors.length > 0
|| result.selectedMarketplaces.length !== 1
|| result.selectedMarketplaces[0] !== OFFICIAL_MARKETPLACE_NAME
|| upgradedRoot !== expectedRoot
) {
fail(
'MARKETPLACE_REFRESH_FAILED',
'Codex did not confirm that the official ECC marketplace was refreshed.',
{ phase, argv }
);
}
return result;
}
function findEccMarketplace(marketplaces) {
return marketplaces.find(
marketplace => marketplace.name === OFFICIAL_MARKETPLACE_NAME
) || null;
}
function findInstalledEccPlugin(inventory) {
return inventory.installed.find(
plugin => plugin.pluginId === CODEX_PLUGIN_ID
) || null;
}
async function readMarketplaceInventory(run, phase) {
const result = await run(
['plugin', 'marketplace', 'list', '--json'],
{ phase }
);
return parseMarketplaceInventory(result.stdout, phase);
}
async function readPluginInventory(run, phase) {
const result = await run(['plugin', 'list', '--json'], { phase });
return parsePluginInventory(result.stdout, phase);
}
async function reconcileCodexPlugin(options = {}, dependencies = {}) {
const run = (args, details = {}) => runCodexCommand(
args,
{
command: options.command,
cwd: options.cwd,
env: options.env,
phase: details.phase,
},
dependencies
);
const marketplaces = await readMarketplaceInventory(run, 'marketplace-inventory');
const plugins = await readPluginInventory(run, 'plugin-inventory');
const marketplace = findEccMarketplace(marketplaces);
const installedPlugin = findInstalledEccPlugin(plugins);
await assertOfficialMarketplace(marketplace, options, dependencies);
const pluginReady = (
installedPlugin?.installed === true
&& installedPlugin.enabled === true
);
const isReconciled = Boolean(marketplace && pluginReady);
if (options.dryRun) {
return {
action: isReconciled
? 'unchanged'
: (installedPlugin ? 'would-update' : 'would-install'),
dryRun: true,
marketplaceAction: marketplace
? 'would-upgrade'
: 'would-add',
pluginId: CODEX_PLUGIN_ID,
restartRequired: !isReconciled,
};
}
const marketplaceArgs = marketplace
? ['plugin', 'marketplace', 'upgrade', OFFICIAL_MARKETPLACE_NAME, '--json']
: ['plugin', 'marketplace', 'add', OFFICIAL_MARKETPLACE_REPO, '--json'];
const marketplaceAction = marketplace ? 'upgraded' : 'added';
const marketplaceResult = await run(marketplaceArgs, {
phase: marketplace ? 'marketplace-upgrade' : 'marketplace-add',
});
if (marketplace) {
parseMarketplaceUpgradeResult(marketplaceResult.stdout, marketplace);
}
const verifiedMarketplaces = await readMarketplaceInventory(
run,
'marketplace-verification'
);
if (!findEccMarketplace(verifiedMarketplaces)) {
fail(
'MARKETPLACE_VERIFICATION_FAILED',
'Could not verify the ECC marketplace after reconciliation.',
{ phase: 'marketplace-verification' }
);
}
await assertOfficialMarketplace(
findEccMarketplace(verifiedMarketplaces),
options,
dependencies,
'marketplace-verification'
);
const pluginsAfterMarketplace = marketplace
? await readPluginInventory(run, 'plugin-verification')
: plugins;
const pluginAfterMarketplace = findInstalledEccPlugin(pluginsAfterMarketplace);
const pluginReadyAfterMarketplace = (
pluginAfterMarketplace?.installed === true
&& pluginAfterMarketplace.enabled === true
);
if (!pluginReadyAfterMarketplace) {
await run(
['plugin', 'add', CODEX_PLUGIN_ID, '--json'],
{ phase: 'plugin-add' }
);
}
const verifiedPlugins = pluginReadyAfterMarketplace
? pluginsAfterMarketplace
: await readPluginInventory(run, 'plugin-verification');
const verifiedPlugin = findInstalledEccPlugin(verifiedPlugins);
if (!(verifiedPlugin?.installed === true && verifiedPlugin.enabled === true)) {
fail(
'PLUGIN_VERIFICATION_FAILED',
`Could not verify ${CODEX_PLUGIN_ID} as installed and enabled after reconciliation.`,
{ phase: 'plugin-verification' }
);
}
return {
action: installedPlugin ? 'updated' : 'installed',
marketplaceAction,
pluginId: CODEX_PLUGIN_ID,
restartRequired: marketplaceAction === 'upgraded' || !pluginReadyAfterMarketplace,
};
}
module.exports = {
CODEX_PLUGIN_ID,
CodexPluginSetupError,
OFFICIAL_MARKETPLACE_NAME,
OFFICIAL_MARKETPLACE_REPO,
PROVIDER_COMMAND_TIMEOUT_MS,
executeFile,
findEccMarketplace,
findInstalledEccPlugin,
normalizeGitHubGitOrigin,
parseMarketplaceInventory,
parseMarketplaceUpgradeResult,
parsePluginInventory,
reconcileCodexPlugin,
resolveMarketplaceRepository,
runCodexCommand,
};
+14
View File
@@ -0,0 +1,14 @@
'use strict';
function normalizeGitHubGitOrigin(value) {
if (typeof value !== 'string') return null;
const normalized = value.trim().replace(/\.git$/i, '').replace(/\/+$/, '');
const match = normalized.match(
/^(?:https:\/\/github\.com\/|ssh:\/\/git@github\.com\/|git@github\.com:)([^/]+\/[^/]+)$/i
);
return match ? match[1].toLowerCase() : null;
}
module.exports = {
normalizeGitHubGitOrigin,
};
+360
View File
@@ -0,0 +1,360 @@
const path = require('path');
const { SUPPORTED_INSTALL_TARGETS } = require('./install-manifests');
const { listInstallTargetAdapters } = require('./install-targets/registry');
function deepFreeze(value) {
if (!value || typeof value !== 'object' || Object.isFrozen(value)) {
return value;
}
for (const child of Object.values(value)) {
deepFreeze(child);
}
return Object.freeze(value);
}
function scope(id, targetId, root) {
return { id, targetId, root };
}
function hooks(mode, eccConfigured, note) {
return {
mode,
eccConfigured,
note,
summary: note,
};
}
const HARNESS_CAPABILITIES = deepFreeze([
{
id: 'claude',
label: 'Claude Code',
targetIds: ['claude', 'claude-project'],
channel: 'native-plugin',
installMode: 'native-plugin',
guidedReady: true,
availability: 'guided',
destination: 'Selected Claude plugin scope: ~/.claude or ./.claude',
scopes: [
scope('user', 'claude', '~/.claude'),
scope('project', 'claude-project', './.claude'),
scope('local', 'claude-project', './.claude'),
],
hooks: hooks(
'profile-selection',
true,
'ECC hooks are configured through the selected off, minimal, standard, or strict profile.'
),
aliases: ['claude-code'],
},
{
id: 'codex',
label: 'Codex',
targetIds: ['codex'],
channel: 'native-plugin',
installMode: 'native-plugin',
guidedReady: true,
availability: 'guided',
destination: '~/.codex through the Codex native plugin lifecycle',
scopes: [scope('native', 'codex', '~/.codex')],
hooks: hooks(
'native-trust',
true,
'ECC hooks use Codex native plugin discovery and remain subject to Codex review and trust.'
),
aliases: ['openai-codex'],
},
{
id: 'kimi',
label: 'Kimi Code',
targetIds: ['kimi'],
channel: 'managed-project',
installMode: 'managed-project',
guidedReady: true,
availability: 'guided',
destination: './.kimi-code',
scopes: [scope('project', 'kimi', './.kimi-code')],
hooks: hooks(
'not-configured',
false,
'ECC hooks are not configured for the Kimi managed-project install.'
),
aliases: ['kimi-code'],
},
{
id: 'cursor',
label: 'Cursor',
targetIds: ['cursor'],
channel: 'managed-project',
installMode: 'managed-project',
guidedReady: false,
availability: 'advanced',
destination: './.cursor',
scopes: [scope('project', 'cursor', './.cursor')],
hooks: hooks(
'adapter-configured',
true,
'ECC hooks use the Cursor project adapter and Cursor event configuration.'
),
aliases: [],
},
{
id: 'antigravity',
label: 'Antigravity',
targetIds: ['antigravity'],
channel: 'managed-project',
installMode: 'managed-project',
guidedReady: false,
availability: 'advanced',
destination: './.agent',
scopes: [scope('project', 'antigravity', './.agent')],
hooks: hooks('not-configured', false, 'ECC hooks are not configured by this adapter.'),
aliases: ['google-antigravity'],
},
{
id: 'gemini',
label: 'Gemini CLI',
targetIds: ['gemini'],
channel: 'managed-project',
installMode: 'managed-project',
guidedReady: false,
availability: 'advanced',
destination: './.gemini',
scopes: [scope('project', 'gemini', './.gemini')],
hooks: hooks('not-configured', false, 'ECC hooks are not configured by this adapter.'),
aliases: ['gemini-cli'],
},
{
id: 'opencode',
label: 'OpenCode',
targetIds: ['opencode'],
channel: 'managed-home',
installMode: 'managed-home',
guidedReady: false,
availability: 'advanced',
destination: '~/.opencode',
scopes: [scope('home', 'opencode', '~/.opencode')],
hooks: hooks(
'adapter-opt-in',
false,
'ECC hook runtime support is available through the OpenCode adapter but is not installed by default.'
),
aliases: ['open-code'],
},
{
id: 'codebuddy',
label: 'CodeBuddy',
targetIds: ['codebuddy'],
channel: 'managed-project',
installMode: 'managed-project',
guidedReady: false,
availability: 'advanced',
destination: './.codebuddy',
scopes: [scope('project', 'codebuddy', './.codebuddy')],
hooks: hooks(
'managed-files',
true,
'ECC hook runtime files are installed through the CodeBuddy project adapter.'
),
aliases: ['code-buddy'],
},
{
id: 'joycode',
label: 'JoyCode',
targetIds: ['joycode'],
channel: 'managed-project',
installMode: 'managed-project',
guidedReady: false,
availability: 'advanced',
destination: './.joycode',
scopes: [scope('project', 'joycode', './.joycode')],
hooks: hooks('not-configured', false, 'ECC hooks are not configured by this adapter.'),
aliases: ['joy-code'],
},
{
id: 'qwen',
label: 'Qwen Code',
targetIds: ['qwen'],
channel: 'managed-home',
installMode: 'managed-home',
guidedReady: false,
availability: 'advanced',
destination: '~/.qwen',
scopes: [scope('home', 'qwen', '~/.qwen')],
hooks: hooks('not-configured', false, 'ECC hooks are not configured by this adapter.'),
aliases: ['qwen-code'],
},
{
id: 'zed',
label: 'Zed',
targetIds: ['zed'],
channel: 'managed-project',
installMode: 'managed-project',
guidedReady: false,
availability: 'advanced',
destination: './.zed',
scopes: [scope('project', 'zed', './.zed')],
hooks: hooks('not-configured', false, 'ECC hooks are not configured by this adapter.'),
aliases: [],
},
{
id: 'hermes',
label: 'Hermes',
targetIds: ['hermes'],
channel: 'managed-home',
installMode: 'managed-home',
guidedReady: false,
availability: 'advanced',
destination: '~/.hermes',
scopes: [scope('home', 'hermes', '~/.hermes')],
hooks: hooks('not-configured', false, 'ECC hooks are not configured by this adapter.'),
aliases: ['hermes-agent'],
},
{
id: 'openclaw',
label: 'OpenClaw',
targetIds: ['openclaw'],
channel: 'managed-home',
installMode: 'managed-home',
guidedReady: false,
availability: 'advanced',
destination: '~/.openclaw',
scopes: [scope('home', 'openclaw', '~/.openclaw')],
hooks: hooks('not-configured', false, 'ECC hooks are not configured by this adapter.'),
aliases: ['open-claw'],
},
]);
const GUIDED_HARNESS_IDS = deepFreeze(
HARNESS_CAPABILITIES
.filter(harness => harness.guidedReady)
.map(harness => harness.id)
);
function normalizeLookupToken(value) {
return String(value).trim().toLowerCase().replace(/[\s_]+/g, '-');
}
const LOOKUP = new Map();
for (const harness of HARNESS_CAPABILITIES) {
const keys = [harness.id, harness.label, ...harness.targetIds, ...harness.aliases];
for (const key of keys) {
LOOKUP.set(normalizeLookupToken(key), harness);
}
}
function expectedRootForAdapter(adapter) {
const homeDir = path.resolve('/__ecc_catalog_home__');
const projectRoot = path.resolve('/__ecc_catalog_project__');
const absoluteRoot = adapter.resolveRoot({ homeDir, projectRoot });
const baseRoot = adapter.kind === 'home' ? homeDir : projectRoot;
const prefix = adapter.kind === 'home' ? '~/' : './';
return `${prefix}${path.relative(baseRoot, absoluteRoot).replace(/\\/g, '/')}`;
}
function validateCatalog() {
const adapters = listInstallTargetAdapters();
const adapterByTarget = new Map(adapters.map(adapter => [adapter.target, adapter]));
const catalogTargetIds = HARNESS_CAPABILITIES.flatMap(harness => harness.targetIds);
if (new Set(catalogTargetIds).size !== catalogTargetIds.length) {
throw new Error('Harness capability catalog contains duplicate install target ids');
}
const supported = [...SUPPORTED_INSTALL_TARGETS].sort();
const registered = adapters.map(adapter => adapter.target).sort();
const catalogued = [...catalogTargetIds].sort();
if (
JSON.stringify(catalogued) !== JSON.stringify(supported)
|| JSON.stringify(catalogued) !== JSON.stringify(registered)
) {
throw new Error('Harness capability catalog is out of sync with install targets');
}
for (const harness of HARNESS_CAPABILITIES) {
for (const declaredScope of harness.scopes) {
const adapter = adapterByTarget.get(declaredScope.targetId);
if (!adapter || expectedRootForAdapter(adapter) !== declaredScope.root) {
throw new Error(
`Harness capability root is out of sync for target ${declaredScope.targetId}`
);
}
}
}
}
validateCatalog();
function listHarnessCapabilities() {
return HARNESS_CAPABILITIES.slice();
}
function listGuidedHarnesses() {
return GUIDED_HARNESS_IDS.map(id => LOOKUP.get(id));
}
function getHarnessCapability(value) {
if (typeof value !== 'string' || value.trim() === '') {
return null;
}
return LOOKUP.get(normalizeLookupToken(value)) || null;
}
function tokenizeSelection(selection) {
const values = Array.isArray(selection) ? selection : [selection];
return values.flatMap(value => (
typeof value === 'string' ? value.split(',') : []
)).map(value => value.trim()).filter(Boolean);
}
function normalizeHarnessSelection(selection) {
const tokens = tokenizeSelection(selection);
if (tokens.length === 0 || tokens.every(token => normalizeLookupToken(token) === 'none')) {
throw new Error('At least one guided harness must be selected');
}
const allTokens = tokens.filter(token => ['all', '*'].includes(normalizeLookupToken(token)));
const explicitTokens = tokens.filter(token => !['all', '*'].includes(normalizeLookupToken(token)));
if (allTokens.length > 0 && explicitTokens.length > 0) {
throw new Error('The all/* harness selection cannot be combined with other selections');
}
if (allTokens.length > 0) {
return GUIDED_HARNESS_IDS.slice();
}
const selected = new Set();
for (const token of tokens) {
const normalizedToken = normalizeLookupToken(token);
const menuIndex = /^\d+$/.test(normalizedToken) ? Number(normalizedToken) - 1 : -1;
const harness = menuIndex >= 0
? listGuidedHarnesses()[menuIndex] || null
: getHarnessCapability(token);
if (!harness) {
throw new Error(`Unknown guided harness selection: ${token}`);
}
if (!harness.guidedReady) {
throw new Error(`${harness.label} is an advanced harness and is not guided-ready`);
}
selected.add(harness.id);
}
if (selected.size === 0) {
throw new Error('At least one guided harness must be selected');
}
return GUIDED_HARNESS_IDS.filter(id => selected.has(id));
}
module.exports = {
GUIDED_HARNESS_IDS,
HARNESS_CAPABILITIES,
getHarnessCapability,
listGuidedHarnesses,
listHarnessCapabilities,
normalizeHarnessSelection,
};
+82 -8
View File
@@ -3,25 +3,90 @@
* Shared hook enable/disable controls.
*
* Controls:
* - ECC_HOOKS_ENABLED=true|false (default: true)
* - ECC_HOOK_PROFILE=minimal|standard|strict (default: standard)
* - ECC_DISABLED_HOOKS=comma,separated,hook,ids
*
* Claude plugin options are used when their corresponding ECC variable is
* absent. A managed install can provide ecc/setup.json as the final fallback.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const VALID_PROFILES = new Set(['minimal', 'standard', 'strict']);
function normalizeId(value) {
return String(value || '').trim().toLowerCase();
}
function getHookProfile() {
const raw = String(process.env.ECC_HOOK_PROFILE || 'standard').trim().toLowerCase();
function parseBoolean(value, fallback = true) {
if (value === undefined || value === null || String(value).trim() === '') {
return fallback;
}
const normalized = String(value).trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
return fallback;
}
function sanitizeDiagnostic(value) {
return String(value || '')
// eslint-disable-next-line no-control-regex
.replace(/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|\([A-Z]|[A-Z])/g, '')
.replace(/[^\x20-\x7E]/g, '?');
}
function readManagedHookConfig(env = process.env) {
const pluginRoot = String(
env.CLAUDE_PLUGIN_ROOT || env.ECC_PLUGIN_ROOT || ''
).trim();
const configPath = String(env.ECC_HOOK_CONFIG || '').trim()
|| (pluginRoot ? path.join(pluginRoot, 'ecc', 'setup.json') : '');
if (!configPath || !fs.existsSync(configPath)) return {};
try {
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
return config?.hooks
&& typeof config.hooks === 'object'
&& !Array.isArray(config.hooks)
? config.hooks
: {};
} catch (error) {
process.stderr.write(`${sanitizeDiagnostic(
`Warning: unable to read managed ECC hook config at ${configPath}: ${error.message}`
)}\n`);
return {};
}
}
function areHooksEnabled(env = process.env, managed = readManagedHookConfig(env)) {
const raw = env.ECC_HOOKS_ENABLED !== undefined
? env.ECC_HOOKS_ENABLED
: (
env.CLAUDE_PLUGIN_OPTION_HOOKS_ENABLED !== undefined
? env.CLAUDE_PLUGIN_OPTION_HOOKS_ENABLED
: managed.enabled
);
return parseBoolean(raw, true);
}
function getHookProfile(env = process.env, managed = readManagedHookConfig(env)) {
const selected = env.ECC_HOOK_PROFILE !== undefined
? env.ECC_HOOK_PROFILE
: (
env.CLAUDE_PLUGIN_OPTION_HOOK_PROFILE !== undefined
? env.CLAUDE_PLUGIN_OPTION_HOOK_PROFILE
: managed.profile
);
const raw = String(selected ?? 'standard').trim().toLowerCase();
return VALID_PROFILES.has(raw) ? raw : 'standard';
}
function getDisabledHookIds() {
const raw = String(process.env.ECC_DISABLED_HOOKS || '');
function getDisabledHookIds(env = process.env) {
const raw = String(env.ECC_DISABLED_HOOKS || '');
if (!raw.trim()) return new Set();
return new Set(
@@ -50,20 +115,26 @@ function parseProfiles(rawProfiles, fallback = ['standard', 'strict']) {
return parsed.length > 0 ? parsed : [...fallback];
}
function isDryRun() {
return process.env.ECC_DRY_RUN === '1';
function isDryRun(env = process.env) {
return env.ECC_DRY_RUN === '1';
}
function isHookEnabled(hookId, options = {}) {
const env = options.env || process.env;
const managed = readManagedHookConfig(env);
if (!areHooksEnabled(env, managed)) {
return false;
}
const id = normalizeId(hookId);
if (!id) return true;
const disabled = getDisabledHookIds();
const disabled = getDisabledHookIds(env);
if (disabled.has(id)) {
return false;
}
const profile = getHookProfile();
const profile = getHookProfile(env, managed);
const allowedProfiles = parseProfiles(options.profiles);
return allowedProfiles.includes(profile);
}
@@ -71,6 +142,9 @@ function isHookEnabled(hookId, options = {}) {
module.exports = {
VALID_PROFILES,
normalizeId,
parseBoolean,
readManagedHookConfig,
areHooksEnabled,
getHookProfile,
getDisabledHookIds,
parseProfiles,
+2 -2
View File
@@ -118,9 +118,9 @@ function createStatePreview(options) {
return createInstallState(options);
}
function applyInstallPlan(plan) {
function applyInstallPlan(plan, dependencies = {}) {
const { applyInstallPlan: applyPlan } = require('./install/apply');
return applyPlan(plan);
return applyPlan(plan, dependencies);
}
function previewInstallPlan(plan) {
+6
View File
@@ -195,6 +195,12 @@ function createFallbackValidator() {
if (typeof operation.scaffoldOnly !== 'boolean') {
pushError(`${instancePath}/scaffoldOnly`, 'must be boolean');
}
if (
operation.contentSha256 !== undefined
&& !/^[a-f0-9]{64}$/i.test(operation.contentSha256)
) {
pushError(`${instancePath}/contentSha256`, 'must be a SHA-256 hex digest');
}
}
}
+1
View File
@@ -9,6 +9,7 @@ const PLATFORM_SOURCE_PATH_OWNERS = Object.freeze({
'.gemini': 'gemini',
'.hermes': 'hermes',
'.kimi': 'kimi',
'.kimi-code': 'kimi',
'.joycode': 'joycode',
'.opencode': 'opencode',
'.openclaw': 'openclaw',
+102 -3
View File
@@ -1,10 +1,109 @@
const { createInstallTargetAdapter } = require('./helpers');
const fs = require('fs');
const path = require('path');
const {
createInstallTargetAdapter,
createManagedOperation,
isForeignPlatformPath,
} = require('./helpers');
function readJsonObject(filePath, label) {
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (error) {
throw new Error(`Failed to parse ${label} at ${filePath}: ${error.message}`);
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(`Invalid ${label} at ${filePath}: expected a JSON object`);
}
return parsed;
}
function createMcpMergeOperation(moduleId, repoRoot, targetRoot) {
if (!repoRoot) {
throw new Error('repoRoot is required to plan Kimi MCP configuration');
}
const sourceRelativePath = '.mcp.json';
const sourcePath = path.join(repoRoot, sourceRelativePath);
if (!fs.existsSync(sourcePath) || !fs.statSync(sourcePath).isFile()) {
return null;
}
return createManagedOperation({
kind: 'merge-json',
moduleId,
sourceRelativePath,
destinationPath: path.join(targetRoot, 'mcp.json'),
strategy: 'merge-json',
scaffoldOnly: false,
mergePayload: readJsonObject(sourcePath, sourceRelativePath),
});
}
module.exports = createInstallTargetAdapter({
id: 'kimi-project',
target: 'kimi',
kind: 'project',
rootSegments: ['.kimi'],
rootSegments: ['.kimi-code'],
installStatePathSegments: ['ecc-install-state.json'],
nativeRootRelativePath: '.kimi',
nativeRootRelativePath: '.kimi-code',
planOperations(input, adapter) {
const modules = Array.isArray(input.modules)
? input.modules
: (input.module ? [input.module] : []);
const planningInput = {
repoRoot: input.repoRoot,
projectRoot: input.projectRoot,
homeDir: input.homeDir,
};
const targetRoot = adapter.resolveRoot(planningInput);
return modules.flatMap(module => {
const paths = Array.isArray(module.paths) ? module.paths : [];
return paths
.filter(sourceRelativePath => !isForeignPlatformPath(sourceRelativePath, adapter.target))
.flatMap(sourceRelativePath => {
if (sourceRelativePath === '.kimi') {
// The repository's compatibility documentation still lives in
// .kimi/. Sync its children into the current native root without
// creating that obsolete directory in the destination project.
return [createManagedOperation({
moduleId: module.id,
sourceRelativePath,
destinationPath: targetRoot,
strategy: 'sync-root-children',
})];
}
if (sourceRelativePath === '.agents') {
const skillsSourcePath = path.join(input.repoRoot || '', '.agents', 'skills');
if (!input.repoRoot || !fs.existsSync(skillsSourcePath)) {
return [];
}
return [createManagedOperation({
moduleId: module.id,
sourceRelativePath: '.agents/skills',
destinationPath: path.join(targetRoot, 'skills'),
strategy: 'preserve-relative-path',
})];
}
if (sourceRelativePath === 'mcp-configs') {
const mcpMergeOperation = createMcpMergeOperation(module.id, input.repoRoot, targetRoot);
return [
adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput),
...(mcpMergeOperation ? [mcpMergeOperation] : []),
];
}
return [adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput)];
});
});
},
});
+89 -6
View File
@@ -1,10 +1,12 @@
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { writeInstallState } = require('../install-state');
const { filterMcpConfig, parseDisabledMcpServers } = require('../mcp-config');
const { assertWithinTrustedRoot } = require('../path-safety');
const {
assertSafeClaudeSkillOperation,
prepareClaudeSkillMigration,
@@ -50,6 +52,27 @@ function readJsonObject(filePath, label) {
return parsed;
}
function stateWithContentDigests(state) {
return {
...state,
operations: (state.operations || []).map(operation => {
if (
!operation.destinationPath
|| !fs.existsSync(operation.destinationPath)
|| !fs.statSync(operation.destinationPath).isFile()
) {
return { ...operation };
}
return {
...operation,
contentSha256: crypto.createHash('sha256')
.update(fs.readFileSync(operation.destinationPath))
.digest('hex'),
};
}),
};
}
function cloneJsonValue(value) {
if (value === undefined) {
return undefined;
@@ -107,9 +130,12 @@ function replacePluginRootPlaceholders(value, pluginRoot) {
return value;
}
function findHooksSourcePath(plan, hooksDestinationPath) {
const operation = plan.operations.find(item => item.destinationPath === hooksDestinationPath);
return operation ? operation.sourcePath : null;
function findHooksOperation(plan, hooksDestinationPath) {
return plan.operations.find(item => (
item.destinationPath === hooksDestinationPath
&& item.moduleId === 'hooks-runtime'
&& typeof item.sourcePath === 'string'
));
}
function isMcpConfigPath(filePath) {
@@ -117,6 +143,38 @@ function isMcpConfigPath(filePath) {
return basename === '.mcp.json' || basename === 'mcp.json';
}
function assertSafeInstallOperation(plan, operation) {
if (!operation || typeof operation.destinationPath !== 'string') {
throw new Error('Refusing to apply install operation: missing destination path.');
}
const targetRoot = plan && plan.targetRoot;
assertWithinTrustedRoot(operation.destinationPath, targetRoot, 'install ECC file');
const resolvedRoot = path.resolve(targetRoot);
const resolvedTarget = path.resolve(operation.destinationPath);
const relativePath = path.relative(resolvedRoot, resolvedTarget);
const segments = relativePath ? relativePath.split(path.sep) : [];
for (const segmentIndex of Array.from({ length: segments.length + 1 }, (_value, index) => index)) {
const currentPath = segmentIndex === 0
? resolvedRoot
: path.join(resolvedRoot, ...segments.slice(0, segmentIndex));
try {
const stats = fs.lstatSync(currentPath);
if (stats.isSymbolicLink()) {
throw new Error(
`Refusing to install ECC file through symlinked path: '${currentPath}'.`
);
}
} catch (error) {
if (error && error.code === 'ENOENT') {
break;
}
throw error;
}
}
}
function buildResolvedClaudeHooks(plan) {
if (!plan.adapter || (plan.adapter.target !== 'claude' && plan.adapter.target !== 'claude-project')) {
return null;
@@ -124,7 +182,11 @@ function buildResolvedClaudeHooks(plan) {
const pluginRoot = plan.targetRoot;
const hooksDestinationPath = path.join(plan.targetRoot, 'hooks', 'hooks.json');
const hooksSourcePath = findHooksSourcePath(plan, hooksDestinationPath) || hooksDestinationPath;
const hooksOperation = findHooksOperation(plan, hooksDestinationPath);
if (!hooksOperation) {
return null;
}
const hooksSourcePath = hooksOperation.sourcePath;
if (!fs.existsSync(hooksSourcePath)) {
return null;
}
@@ -136,6 +198,7 @@ function buildResolvedClaudeHooks(plan) {
}
return {
hooksOperation,
hooksDestinationPath,
resolvedHooksConfig: {
...hooksConfig,
@@ -162,6 +225,8 @@ function previewInstallPlan(plan) {
function applyInstallPlan(plan, dependencies = {}) {
const persistInstallState = dependencies.writeInstallState || writeInstallState;
const beforeOperationWrite = dependencies.beforeOperationWrite;
const beforeInstallStateWrite = dependencies.beforeInstallStateWrite;
const migration = prepareClaudeSkillMigration(plan);
const appliedPlan = {
...plan,
@@ -177,16 +242,24 @@ function applyInstallPlan(plan, dependencies = {}) {
// before the first copy. A later failure is retryable and uninstall can
// clean the entire partial install, including non-skill files. During
// legacy migration the bridge also retains the prior managed operations.
if (typeof beforeInstallStateWrite === 'function') {
beforeInstallStateWrite({ plan: appliedPlan, state: migration.bridgeState });
}
persistInstallState(plan.installStatePath, migration.bridgeState);
}
for (const operation of appliedPlan.operations) {
assertSafeInstallOperation(appliedPlan, operation);
assertSafeClaudeSkillOperation(appliedPlan, operation);
fs.mkdirSync(path.dirname(operation.destinationPath), { recursive: true });
// Recheck directories that were absent during the first validation. This
// narrows the symlink-swap window around mkdirSync, but path checks cannot
// eliminate a later TOCTOU race before the file write.
assertSafeInstallOperation(appliedPlan, operation);
assertSafeClaudeSkillOperation(appliedPlan, operation);
if (typeof beforeOperationWrite === 'function') {
beforeOperationWrite({ plan: appliedPlan, operation });
}
if (operation.kind === 'merge-json') {
const payload = cloneJsonValue(operation.mergePayload);
@@ -236,7 +309,12 @@ function applyInstallPlan(plan, dependencies = {}) {
}
if (resolvedClaudeHooksPlan) {
assertSafeInstallOperation(appliedPlan, resolvedClaudeHooksPlan.hooksOperation);
fs.mkdirSync(path.dirname(resolvedClaudeHooksPlan.hooksDestinationPath), { recursive: true });
assertSafeInstallOperation(appliedPlan, resolvedClaudeHooksPlan.hooksOperation);
if (typeof beforeOperationWrite === 'function') {
beforeOperationWrite({ plan: appliedPlan, operation: resolvedClaudeHooksPlan.hooksOperation });
}
fs.writeFileSync(
resolvedClaudeHooksPlan.hooksDestinationPath,
JSON.stringify(resolvedClaudeHooksPlan.resolvedHooksConfig, null, 2) + '\n',
@@ -247,11 +325,15 @@ function applyInstallPlan(plan, dependencies = {}) {
if (hasLegacyMigration) {
removeLegacyClaudeSkillFiles(migration, plan.targetRoot);
}
persistInstallState(plan.installStatePath, migration.finalState);
const finalState = stateWithContentDigests(migration.finalState);
if (typeof beforeInstallStateWrite === 'function') {
beforeInstallStateWrite({ plan: appliedPlan, state: finalState });
}
persistInstallState(plan.installStatePath, finalState);
return {
...plan,
statePreview: migration.finalState,
statePreview: finalState,
plannedOperations: [...plan.operations],
operations: migration.appliedOperations,
skippedOperations: migration.skippedOperations,
@@ -265,5 +347,6 @@ function applyInstallPlan(plan, dependencies = {}) {
module.exports = {
applyInstallPlan,
assertSafeInstallOperation,
previewInstallPlan,
};
+148
View File
@@ -0,0 +1,148 @@
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const { isWithinRoot, realpathNearestExisting } = require('../path-safety');
const CURRENT_PLUGIN_ID = 'ecc@ecc';
const LEGACY_PLUGIN_IDS = new Set([
'everything-claude-code@everything-claude-code',
'everything-claude-code@ecc',
]);
function resolveClaudePaths(options = {}) {
const homeDir = options.homeDir
|| process.env.HOME
|| process.env.USERPROFILE
|| os.homedir();
const configDir = options.configDir
|| process.env.CLAUDE_CONFIG_DIR
|| path.join(homeDir, '.claude');
const projectRoot = options.projectRoot || process.cwd();
return {
homeDir: path.resolve(homeDir),
configDir: path.resolve(configDir),
projectRoot: path.resolve(projectRoot),
};
}
function readJsonObject(filePath, label) {
let value;
try {
value = JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (error) {
throw new Error(`${label} is invalid at ${filePath}: ${error.message}`);
}
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`${label} is invalid at ${filePath}: expected a JSON object`);
}
return value;
}
function findManualClaudePlugin(options = {}) {
const { configDir } = resolveClaudePaths(options);
const pluginsDir = path.join(configDir, 'plugins');
const candidates = [
['ecc', '.claude-plugin', 'plugin.json'],
['ecc', 'plugin.json'],
['ecc@ecc', '.claude-plugin', 'plugin.json'],
['ecc@ecc', 'plugin.json'],
['everything-claude-code', '.claude-plugin', 'plugin.json'],
['everything-claude-code', 'plugin.json'],
];
for (const segments of candidates) {
const manifestPath = path.join(pluginsDir, ...segments);
if (fs.existsSync(manifestPath)) {
return {
manifestPath,
installPath: path.dirname(path.dirname(manifestPath)),
};
}
}
return null;
}
function validateManagedState(state, statePath, expectedRoot) {
const selectedModules = state?.resolution?.selectedModules;
const operations = state?.operations;
if (
state?.schemaVersion !== 'ecc.install.v1'
|| !state.target
|| typeof state.target !== 'object'
|| Array.isArray(state.target)
|| !Array.isArray(selectedModules)
|| !selectedModules.every(moduleId => typeof moduleId === 'string' && moduleId.length > 0)
|| !Array.isArray(operations)
) {
throw new Error(`Managed Claude install-state is invalid at ${statePath}`);
}
for (const operation of operations) {
if (
!operation
|| typeof operation !== 'object'
|| typeof operation.destinationPath !== 'string'
|| !path.isAbsolute(operation.destinationPath)
|| !isWithinRoot(operation.destinationPath, expectedRoot)
) {
throw new Error(`Managed Claude install-state is invalid at ${statePath}`);
}
}
return { selectedModules, operations };
}
function operationOverlapsPlugin(operation, expectedRoot) {
const canonicalRoot = realpathNearestExisting(expectedRoot);
const canonicalDestination = realpathNearestExisting(operation.destinationPath);
const relativePath = path.relative(canonicalRoot, canonicalDestination);
const firstSegment = relativePath.split(path.sep)[0];
return ['agents', 'commands', 'hooks', 'skills'].includes(firstSegment);
}
function findManagedClaudeInstalls(options = {}) {
const { configDir, projectRoot } = resolveClaudePaths(options);
const candidates = [
{
statePath: path.join(configDir, 'ecc', 'install-state.json'),
expectedRoot: configDir,
},
{
statePath: path.join(projectRoot, '.claude', 'ecc', 'install-state.json'),
expectedRoot: path.join(projectRoot, '.claude'),
},
];
const findings = [];
for (const candidate of candidates) {
if (!fs.existsSync(candidate.statePath)) continue;
const state = readJsonObject(candidate.statePath, 'Managed Claude install-state');
const { selectedModules, operations } = validateManagedState(
state,
candidate.statePath,
candidate.expectedRoot
);
const modulesOverlap = selectedModules.some(moduleId => moduleId !== 'rules-core');
const operationsOverlap = operations.some(operation => (
operationOverlapsPlugin(operation, candidate.expectedRoot)
));
findings.push({
statePath: candidate.statePath,
selectedModules: [...selectedModules],
overlapsPlugin: modulesOverlap || operationsOverlap,
});
}
return findings;
}
module.exports = {
CURRENT_PLUGIN_ID,
LEGACY_PLUGIN_IDS,
findManagedClaudeInstalls,
findManualClaudePlugin,
resolveClaudePaths,
};
+444
View File
@@ -0,0 +1,444 @@
'use strict';
const fs = require('fs');
const crypto = require('crypto');
const os = require('os');
const path = require('path');
const { assertSafeInstallOperation } = require('./install/apply');
const { assertWithinTrustedRoot, realpathNearestExisting } = require('./path-safety');
const VALID_CLAUDE_SCOPES = new Set(['user', 'project', 'local']);
const VALID_CLAUDE_HOOKS = new Set(['off', 'minimal', 'standard', 'strict']);
const VALID_PROFILES = new Set(['minimal', 'core', 'developer', 'security', 'research', 'full']);
function catalogHelpers() {
return require('./harness-capabilities');
}
function normalizeGuidedInstallRequest(input = {}) {
const { normalizeHarnessSelection } = catalogHelpers();
const harnesses = normalizeHarnessSelection(input.harnesses || []);
if (harnesses.length === 0) {
throw new Error('Choose at least one guided harness: Claude, Codex, or Kimi.');
}
const includesClaude = harnesses.includes('claude');
const includesKimi = harnesses.includes('kimi');
if (!includesClaude && (input.claudeScope !== undefined || input.claudeHooks !== undefined)) {
throw new Error('Claude scope and hook options require Claude to be selected.');
}
if (!includesKimi && input.profile !== undefined) {
throw new Error('The managed install profile requires Kimi to be selected.');
}
const claudeScope = includesClaude ? (input.claudeScope || 'user') : undefined;
const claudeHooks = includesClaude ? (input.claudeHooks || 'standard') : undefined;
const profile = includesKimi ? (input.profile || 'core') : undefined;
if (claudeScope && !VALID_CLAUDE_SCOPES.has(claudeScope)) {
throw new Error(`Invalid Claude scope: ${claudeScope}`);
}
if (claudeHooks && !VALID_CLAUDE_HOOKS.has(claudeHooks)) {
throw new Error(`Invalid Claude hooks preference: ${claudeHooks}`);
}
if (profile && !VALID_PROFILES.has(profile)) {
throw new Error(`Invalid Kimi install profile: ${profile}`);
}
return {
harnesses,
...(claudeHooks ? { claudeHooks } : {}),
...(claudeScope ? { claudeScope } : {}),
dryRun: Boolean(input.dryRun),
json: Boolean(input.json),
...(profile ? { profile } : {}),
yes: Boolean(input.yes),
};
}
function canonicalPath(filePath) {
return realpathNearestExisting(filePath);
}
function pathsMatch(left, right) {
return canonicalPath(left) === canonicalPath(right);
}
function fingerprintFile(filePath) {
if (!fs.existsSync(filePath)) return { exists: false, sha256: null };
return {
exists: true,
sha256: crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'),
};
}
function operationIdentityMatches(stateOperation, plannedOperation) {
return [
'kind',
'moduleId',
'sourceRelativePath',
'strategy',
'scaffoldOnly',
].every(field => stateOperation[field] === plannedOperation[field]);
}
function assertInstallStateUnchanged(plan, expectedFingerprint) {
const currentFingerprint = fingerprintFile(plan.installStatePath);
if (
currentFingerprint.exists !== expectedFingerprint.exists
|| currentFingerprint.sha256 !== expectedFingerprint.sha256
) {
throw new Error(
`Refusing to overwrite an unowned or changed install-state at ${plan.installStatePath}. `
+ 'Re-run the guided preview and review the existing state before retrying.'
);
}
}
function assertPriorInstallStateMatchesPlan(state, plan) {
const target = state.target || {};
const adapter = plan.adapter || {};
if (
target.id !== adapter.id
|| target.target !== adapter.target
|| target.kind !== adapter.kind
) {
throw new Error(
`Refusing to trust managed install-state at ${plan.installStatePath}: `
+ 'target identity does not match the current Kimi install plan.'
);
}
if (!pathsMatch(target.root, plan.targetRoot)) {
throw new Error(
`Refusing to trust managed install-state at ${plan.installStatePath}: `
+ 'recorded root does not match the current install root.'
);
}
if (!pathsMatch(target.installStatePath, plan.installStatePath)) {
throw new Error(
`Refusing to trust managed install-state at ${plan.installStatePath}: `
+ 'recorded install-state path does not match the current install-state path.'
);
}
}
function readOwnedDestinations(plan, dependencies) {
if (!plan.installStatePath) {
return { destinations: new Set(), stateFingerprint: { exists: false, sha256: null } };
}
try {
assertSafeInstallOperation(plan, { destinationPath: plan.installStatePath });
} catch (error) {
throw new Error(`Refusing to trust managed install-state path: ${error.message}`);
}
if (!fs.existsSync(plan.installStatePath)) {
return { destinations: new Set(), stateFingerprint: { exists: false, sha256: null } };
}
const readState = dependencies.readInstallState || require('./install-state').readInstallState;
const initialFingerprint = fingerprintFile(plan.installStatePath);
const state = readState(plan.installStatePath);
const validatedFingerprint = fingerprintFile(plan.installStatePath);
if (
initialFingerprint.exists !== validatedFingerprint.exists
|| initialFingerprint.sha256 !== validatedFingerprint.sha256
) {
throw new Error(
`Refusing to trust install-state that changed during validation: ${plan.installStatePath}.`
);
}
assertPriorInstallStateMatchesPlan(state, plan);
const plannedByDestination = new Map(plan.operations.map(operation => [
canonicalPath(operation.destinationPath),
operation,
]));
const destinations = new Set();
for (const operation of state.operations || []) {
if (operation.ownership !== 'managed') {
throw new Error(
`Refusing to trust non-managed ownership from install-state at ${plan.installStatePath}.`
);
}
const destinationPath = operation.destinationPath;
assertWithinTrustedRoot(destinationPath, plan.targetRoot, 'trust install-state ownership');
const canonicalDestination = canonicalPath(destinationPath);
const plannedOperation = plannedByDestination.get(canonicalDestination);
if (!plannedOperation) continue;
if (!operationIdentityMatches(operation, plannedOperation)) {
throw new Error(
`Refusing unverified ownership from install-state at ${plan.installStatePath}: `
+ `operation identity does not match the current plan for ${destinationPath}.`
);
}
const currentFingerprint = fingerprintFile(destinationPath);
if (
!currentFingerprint.exists
|| !/^[a-f0-9]{64}$/i.test(operation.contentSha256 || '')
|| currentFingerprint.sha256 !== operation.contentSha256.toLowerCase()
) {
throw new Error(
`Refusing unverified ownership from install-state at ${plan.installStatePath}: `
+ `content digest does not match ${destinationPath}.`
);
}
destinations.add(canonicalDestination);
}
return { destinations, stateFingerprint: validatedFingerprint };
}
function assertMergeDestination(destinationPath) {
if (!fs.existsSync(destinationPath)) return null;
let current;
try {
current = JSON.parse(fs.readFileSync(destinationPath, 'utf8'));
} catch (error) {
throw new Error(`Cannot merge ECC configuration into invalid JSON at ${destinationPath}: ${error.message}`);
}
if (!current || typeof current !== 'object' || Array.isArray(current)) {
throw new Error(`Cannot merge ECC configuration at ${destinationPath}: expected a JSON object.`);
}
return current;
}
function isPlainObject(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function findJsonConflicts(current, patch, prefix = '') {
if (!isPlainObject(patch)) return [];
return Object.entries(patch).flatMap(([key, patchValue]) => {
if (!Object.prototype.hasOwnProperty.call(current, key)) return [];
const currentValue = current[key];
const field = prefix ? `${prefix}.${key}` : key;
if (isPlainObject(currentValue) && isPlainObject(patchValue)) {
return findJsonConflicts(currentValue, patchValue, field);
}
return JSON.stringify(currentValue) === JSON.stringify(patchValue) ? [] : [field];
});
}
function classifyManagedOperation(operation, ownedDestinations) {
const destinationPath = operation.destinationPath;
if (!fs.existsSync(destinationPath)) return 'create';
const canonicalDestination = canonicalPath(destinationPath);
if (operation.kind === 'merge-json') {
const current = assertMergeDestination(destinationPath);
if (ownedDestinations.has(canonicalDestination)) return 'managed-json-update';
const conflicts = findJsonConflicts(current, operation.mergePayload);
if (conflicts.length > 0) {
throw new Error(
`Refusing to overwrite unowned JSON fields at ${destinationPath}: ${conflicts.join(', ')}`
);
}
return 'json-merge';
}
if (ownedDestinations.has(canonicalDestination)) return 'managed-update';
if (
operation.kind === 'copy-file'
&& typeof operation.sourcePath === 'string'
&& fs.existsSync(operation.sourcePath)
&& fs.statSync(destinationPath).isFile()
&& fs.readFileSync(operation.sourcePath).equals(fs.readFileSync(destinationPath))
) {
return 'identical';
}
throw new Error(`Refusing to replace unowned existing file: ${destinationPath}`);
}
function writableRequirement(destinationPath) {
if (fs.existsSync(destinationPath)) {
const mode = fs.statSync(destinationPath).isDirectory()
? fs.constants.W_OK | fs.constants.X_OK
: fs.constants.W_OK;
return { candidatePath: destinationPath, mode };
}
let candidatePath = path.dirname(destinationPath);
while (!fs.existsSync(candidatePath)) {
const parentPath = path.dirname(candidatePath);
if (parentPath === candidatePath) break;
candidatePath = parentPath;
}
return {
candidatePath,
mode: fs.constants.W_OK | fs.constants.X_OK,
};
}
function assertManagedDestinationsWritable(plan, dependencies) {
const accessSync = dependencies.accessSync || fs.accessSync;
const destinationPaths = [
...plan.operations.map(operation => operation.destinationPath),
...(plan.installStatePath ? [plan.installStatePath] : []),
];
const requirements = new Map();
for (const destinationPath of destinationPaths) {
const requirement = writableRequirement(destinationPath);
const existingMode = requirements.get(requirement.candidatePath) || 0;
requirements.set(requirement.candidatePath, existingMode | requirement.mode);
}
for (const [candidatePath, mode] of requirements) {
try {
accessSync(candidatePath, mode);
} catch (_error) {
const label = plan.target === 'kimi' ? 'Kimi' : 'Managed install';
throw new Error(
`${label} destination is not writable by the current user: ${candidatePath}. `
+ 'Fix the project ownership or permissions, then retry.'
);
}
}
}
function preflightManagedPlan(plan, dependencies = {}) {
if (!plan || !Array.isArray(plan.operations)) {
throw new Error('A managed install plan with operations is required.');
}
const ownership = readOwnedDestinations(plan, dependencies);
const operations = plan.operations.map(operation => {
assertSafeInstallOperation(plan, operation);
return {
destinationPath: operation.destinationPath,
kind: operation.kind,
classification: classifyManagedOperation(operation, ownership.destinations),
};
});
assertManagedDestinationsWritable(plan, dependencies);
return {
plan,
operations,
ownershipSnapshot: {
destinations: [...ownership.destinations],
stateFingerprint: ownership.stateFingerprint,
},
};
}
function applyPreflightedManagedPlan(entry) {
const preview = entry.preview && entry.preview.ownershipSnapshot
? entry.preview
: preflightManagedPlan(entry.preview.plan);
const ownedDestinations = new Set(preview.ownershipSnapshot.destinations);
const expectedStateFingerprint = preview.ownershipSnapshot.stateFingerprint;
let operationIndex = 0;
const assertStateUnchanged = () => (
assertInstallStateUnchanged(preview.plan, expectedStateFingerprint)
);
return require('./install-executor').applyInstallPlan(preview.plan, {
beforeOperationWrite({ operation }) {
assertStateUnchanged();
const expected = preview.operations[operationIndex];
const currentClassification = classifyManagedOperation(operation, ownedDestinations);
const destination = canonicalPath(operation.destinationPath);
if (
!expected
|| expected.kind !== operation.kind
|| canonicalPath(expected.destinationPath) !== destination
|| expected.classification !== currentClassification
) {
throw new Error(
`Refusing to write ${operation.destinationPath}: destination changed after Kimi preflight.`
);
}
ownedDestinations.add(destination);
operationIndex += 1;
},
beforeInstallStateWrite: assertStateUnchanged,
});
}
function defaultDependencies(options = {}) {
return {
previewClaude: request => require('../setup').reconcileClaudePlugin(
{ dryRun: true, hooks: request.claudeHooks, scope: request.claudeScope }
),
previewCodex: () => require('./codex-plugin-setup').reconcileCodexPlugin({ dryRun: true }),
createManagedPlan: request => require('./install/runtime').createInstallPlanFromRequest(
require('./install/request').normalizeInstallRequest({
profileId: request.profile,
target: 'kimi',
}),
{
homeDir: options.homeDir || process.env.HOME || os.homedir(),
projectRoot: options.projectRoot || process.cwd(),
sourceRoot: options.sourceRoot,
}
),
preflightManaged: preflightManagedPlan,
applyClaude: request => require('../setup').reconcileClaudePlugin(
{ dryRun: false, hooks: request.claudeHooks, scope: request.claudeScope }
),
applyCodex: () => require('./codex-plugin-setup').reconcileCodexPlugin({ dryRun: false }),
applyManaged: applyPreflightedManagedPlan,
};
}
async function createMultiHarnessPlan(request, injected = {}, options = {}) {
const dependencies = { ...defaultDependencies(options), ...injected };
let entries = [];
for (const id of request.harnesses) {
if (id === 'claude') {
entries = [...entries, { id, channel: 'native-plugin', preview: await dependencies.previewClaude(request) }];
} else if (id === 'codex') {
entries = [...entries, { id, channel: 'native-plugin', preview: await dependencies.previewCodex(request) }];
} else if (id === 'kimi') {
const managedPlan = await dependencies.createManagedPlan(request);
entries = [...entries, {
id,
channel: 'managed-project',
preview: await dependencies.preflightManaged(managedPlan),
}];
} else {
throw new Error(`Unsupported guided harness: ${id}`);
}
}
return { harnesses: entries, request };
}
async function applyMultiHarnessPlan(plan, injected = {}, options = {}) {
const dependencies = { ...defaultDependencies(options), ...injected };
if (plan.request.dryRun) {
return { status: 'preview', completed: [], retryHarnesses: [...plan.request.harnesses] };
}
let completed = [];
for (let index = 0; index < plan.harnesses.length; index += 1) {
const entry = plan.harnesses[index];
try {
let result;
if (entry.id === 'claude') result = await dependencies.applyClaude(plan.request, entry);
else if (entry.id === 'codex') result = await dependencies.applyCodex(plan.request, entry);
else if (entry.preview && entry.preview.plan) {
const latestPreview = dependencies.preflightManaged(entry.preview.plan);
result = await dependencies.applyManaged(
{ ...entry, preview: latestPreview },
plan.request
);
} else {
result = await dependencies.applyManaged(entry, plan.request);
}
completed = [...completed, { id: entry.id, result }];
} catch (error) {
return {
status: completed.length > 0 ? 'partial' : 'failed',
completed,
failure: { id: entry.id, message: error.message },
retryHarnesses: plan.harnesses.slice(index).map(item => item.id),
};
}
}
return { status: 'complete', completed, retryHarnesses: [] };
}
module.exports = {
VALID_CLAUDE_HOOKS,
VALID_CLAUDE_SCOPES,
VALID_PROFILES,
applyMultiHarnessPlan,
createMultiHarnessPlan,
normalizeGuidedInstallRequest,
preflightManagedPlan,
findJsonConflicts,
};
+1
View File
@@ -70,6 +70,7 @@ function isWithinRoot(target, root) {
if (!root) {
return false;
}
try {
return resolveContainment(target, root).contained;
} catch {
+77
View File
@@ -0,0 +1,77 @@
'use strict';
const { spawn } = require('child_process');
const CLEAR_LINE = '\r\x1b[2K';
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
const FRAME_INTERVAL_MS = 80;
function runAnimator(label, options = {}) {
const output = options.output || process.stdout;
const schedule = options.schedule || setInterval;
const clearSchedule = options.clearSchedule || clearInterval;
const onDisconnect = options.onDisconnect
|| (handler => process.on('disconnect', handler));
const exit = options.exit || (code => process.exit(code));
let frameIndex = 1;
const timer = schedule(() => {
output.write(`\r${FRAMES[frameIndex]} ${label}`);
frameIndex = (frameIndex + 1) % FRAMES.length;
}, FRAME_INTERVAL_MS);
onDisconnect(() => {
clearSchedule(timer);
exit(0);
});
}
function startTerminalSpinner(label, options = {}) {
const output = options.output || process.stdout;
const spawnProcess = options.spawnProcess || spawn;
const onAnimatorError = options.onAnimatorError;
output.write(`${FRAMES[0]} ${label}`);
let animator;
try {
animator = spawnProcess(
process.execPath,
[__filename, '--animate', label],
{
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
}
);
animator.on?.('error', error => {
// Preserve a stable visible fallback if the child cannot animate.
output.write(`\r${FRAMES[0]} ${label}`);
onAnimatorError?.(error);
});
} catch {
// The first frame still provides visible progress if animation cannot start.
}
let stopped = false;
return {
stop() {
if (stopped) return;
stopped = true;
animator?.once?.('close', () => {
// A child can render between kill() and close; clear that final frame.
output.write(CLEAR_LINE);
});
animator?.kill();
output.write(CLEAR_LINE);
},
};
}
// c8 ignore next 3 -- exercised as the independently instrumented child process.
if (require.main === module && process.argv[2] === '--animate') {
runAnimator(process.argv[3] || 'Working...');
}
module.exports = {
CLEAR_LINE,
FRAMES,
runAnimator,
startTerminalSpinner,
};
+146
View File
@@ -0,0 +1,146 @@
'use strict';
const { version: ECC_VERSION } = require('../../package.json');
const COMMUNITY_LINKS = Object.freeze({
github: 'https://github.com/affaan-m/ECC',
discord: 'https://discord.gg/36yGMHGFbR',
documentation: 'https://github.com/affaan-m/ECC#readme',
githubApp: 'https://github.com/apps/ecc-tools',
});
const SUCCESS_ACTIONS = Object.freeze([
'installed',
'updated',
'migrated',
'resumed',
'already-migrated',
'configured',
]);
const SUCCESS_MESSAGES = Object.freeze({
installed: 'Welcome to ECC!',
updated: 'ECC is updated — thank you for using ECC!',
migrated: 'ECC is configured — thank you for using ECC!',
resumed: 'ECC is configured — thank you for using ECC!',
'already-migrated': 'ECC is configured — thank you for using ECC!',
configured: 'ECC is configured — thank you for using ECC!',
});
// CFonts' default "block" face: https://github.com/dominikwilkowski/cfonts
const ECC_WORDMARK = Object.freeze([
' ███████╗ ██████╗ ██████╗',
' ██╔════╝ ██╔════╝ ██╔════╝',
' █████╗ ██║ ██║',
' ██╔══╝ ██║ ██║',
' ███████╗ ╚██████╗ ╚██████╗',
' ╚══════╝ ╚═════╝ ╚═════╝',
]);
const ECC_GRADIENT = Object.freeze({
start: Object.freeze({ red: 215, green: 151, blue: 107 }),
end: Object.freeze({ red: 100, green: 131, blue: 160 }),
});
const ECC_VERSION_PATTERN = /^[0-9]+(?:\.[0-9]+){2}(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
const WORDMARK_START_COLUMN = Math.min(...ECC_WORDMARK.map(line => line.search(/\S/)));
const WORDMARK_END_COLUMN = Math.max(
...ECC_WORDMARK.map(line => line.trimEnd().length - 1)
);
function colorize(value, code, enabled) {
return enabled ? `\x1b[${code}m${value}\x1b[0m` : value;
}
function interpolateChannel(start, end, ratio) {
return Math.round(start + ((end - start) * ratio));
}
function gradientColorAt(column) {
const span = WORDMARK_END_COLUMN - WORDMARK_START_COLUMN;
const ratio = span === 0 ? 0 : (column - WORDMARK_START_COLUMN) / span;
return {
red: interpolateChannel(ECC_GRADIENT.start.red, ECC_GRADIENT.end.red, ratio),
green: interpolateChannel(ECC_GRADIENT.start.green, ECC_GRADIENT.end.green, ratio),
blue: interpolateChannel(ECC_GRADIENT.start.blue, ECC_GRADIENT.end.blue, ratio),
};
}
function renderWordmark(color) {
if (!color) return ECC_WORDMARK.join('\n');
return ECC_WORDMARK.map(line => (
[...line].map((character, column) => {
if (character === ' ') return character;
const value = gradientColorAt(column);
return `\x1b[38;2;${value.red};${value.green};${value.blue}m${character}`;
}).join('') + '\x1b[0m'
)).join('\n');
}
function renderCommunityLinks() {
const rows = Object.freeze([
`GitHub: ${COMMUNITY_LINKS.github}`,
`Discord: ${COMMUNITY_LINKS.discord}`,
`Documentation: ${COMMUNITY_LINKS.documentation}`,
`GitHub App: ${COMMUNITY_LINKS.githubApp}`,
]);
const contentWidth = Math.max(...rows.map(row => row.length));
const border = '─'.repeat(contentWidth + 2);
return [
`${border}`,
...rows.map(row => `${row.padEnd(contentWidth)}`),
`${border}`,
];
}
function renderTerminalWelcome(options = {}) {
const color = options.color === true;
const installedVersion = options.version || ECC_VERSION;
if (!ECC_VERSION_PATTERN.test(installedVersion)) {
throw new Error(`Invalid ECC version: ${installedVersion}`);
}
const graphic = renderWordmark(color);
const successMessage = SUCCESS_MESSAGES[options.action] || SUCCESS_MESSAGES.installed;
const welcomeMessage = colorize(successMessage, '1;35', color);
const version = colorize(`v${installedVersion}`, '2', color);
const versionLine = color ? `\x1b[1G ${version}` : ` ${version}`;
return [
'',
graphic,
'',
` ${welcomeMessage}`,
versionLine,
'',
...renderCommunityLinks(),
'',
].join('\n');
}
function showTerminalWelcome(options = {}) {
const {
action,
dryRun = false,
env = process.env,
interactive = false,
json = false,
output = process.stdout,
} = options;
const shouldShow = (
interactive
&& output.isTTY === true
&& !dryRun
&& !json
&& SUCCESS_ACTIONS.includes(action)
);
if (!shouldShow) return false;
const color = env.NO_COLOR === undefined && env.TERM !== 'dumb';
output.write(renderTerminalWelcome({ action, color }));
return true;
}
module.exports = {
COMMUNITY_LINKS,
ECC_VERSION_PATTERN,
renderTerminalWelcome,
showTerminalWelcome,
};
+20 -8
View File
@@ -73,6 +73,15 @@ if [[ -z "$OLD_VERSION" ]]; then
echo "Error: Could not extract current version from $PLUGIN_JSON"
exit 1
fi
if [[ "$OLD_VERSION" == "$VERSION" ]]; then
echo "Error: Version $VERSION is already declared in release metadata."
echo "After the merged commit passes CI, publish it through the tag workflow:"
echo " git tag \"v$VERSION\""
echo " git push origin \"v$VERSION\""
exit 1
fi
echo "Bumping version: $OLD_VERSION -> $VERSION"
update_version() {
@@ -165,21 +174,24 @@ update_marketplace_plugin_version() {
update_latest_release_heading() {
local file="$1"
local old_version="$2"
node -e '
const fs = require("fs");
const file = process.argv[1];
const version = process.argv[2];
const oldVersion = process.argv[3];
const escape = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const current = fs.readFileSync(file, "utf8");
const updated = current.replace(
/^### v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?( .*)$/m,
new RegExp(`^### v${escape(oldVersion)}( .*)$`, "m"),
`### v${version}$1`
);
if (updated === current) {
console.error(`Error: could not update latest release heading in ${file}`);
console.error(`Error: could not update release heading for v${oldVersion} in ${file}`);
process.exit(1);
}
fs.writeFileSync(file, updated);
' "$file" "$VERSION"
' "$file" "$VERSION" "$old_version"
}
update_selective_install_repo_version() {
@@ -300,13 +312,13 @@ update_package_lock_version "$OPENCODE_PACKAGE_LOCK_JSON"
update_opencode_hook_banner_version
update_readme_version_row "$README_FILE" "Version" "Plugin" "Plugin" "Reference config"
update_readme_version_row "$ZH_CN_README_FILE" "版本" "插件" "插件" "参考配置"
update_latest_release_heading "$README_FILE"
update_latest_release_heading "$ROOT_ZH_CN_README_FILE"
update_latest_release_heading "$TR_README_FILE"
update_latest_release_heading "$PT_BR_README_FILE"
update_latest_release_heading "$README_FILE" "$OLD_VERSION"
update_latest_release_heading "$ROOT_ZH_CN_README_FILE" "$OLD_VERSION"
update_latest_release_heading "$TR_README_FILE" "$OLD_VERSION"
update_latest_release_heading "$PT_BR_README_FILE" "$OLD_VERSION"
# docs/zh-CN/README.md got its version row bumped but never its release
# heading, so plugin-manifest.test.js failed on it every time.
update_latest_release_heading "$ZH_CN_README_FILE"
update_latest_release_heading "$ZH_CN_README_FILE" "$OLD_VERSION"
update_selective_install_repo_version "$SELECTIVE_INSTALL_ARCHITECTURE_DOC"
# Verify the bumped release surface is still internally consistent before
+504
View File
@@ -0,0 +1,504 @@
#!/usr/bin/env node
'use strict';
const path = require('path');
const readline = require('readline/promises');
const {
ClaudeSetupError,
VALID_HOOK_MODES,
VALID_SCOPES,
deriveHookMode,
readSettings,
setupClaudePlugin,
} = require('./lib/claude-plugin-setup');
const {
migrateClaudePluginScope,
} = require('./lib/claude-scope-migration');
const { resolveClaudePaths } = require('./lib/install/inventory');
const { startTerminalSpinner } = require('./lib/terminal-spinner');
const { showTerminalWelcome } = require('./lib/terminal-welcome');
const MODE = 'claude-plugin';
const AUTO_MIGRATION_CODES = new Set([
'MULTIPLE_PLUGIN_SCOPES',
'SCOPE_MOVE_REQUIRED',
]);
function showHelp() {
process.stdout.write(`
ECC guided setup
Usage:
ecc setup
ecc setup --mode claude-plugin --scope user|project|local [options]
ecc setup --mode claude-plugin --scope project --move-scope [options]
Install scopes:
user Global for this user; ECC is available in every project.
project Shared project configuration; the repository can enable ECC for collaborators.
local Private project configuration; ECC is enabled here without committing the choice.
Hook preferences:
--hooks off|minimal|standard|strict
Save a personal hook preference in Claude user settings.
Options:
--mode claude-plugin
--scope <scope>
--hooks <preference>
--move-scope Explicitly request migration (normally auto-detected).
--yes, -y Skip the confirmation prompt.
--dry-run Inspect and report without changing anything.
--json Emit machine-readable JSON.
--help, -h Show this help.
Re-running setup updates an existing ecc@ecc installation at its detected scope.
Choosing another scope automatically migrates the existing installation.
Migration installs and verifies the destination before removing the source scope.
`);
}
function parseArgs(argv) {
const options = {
dryRun: false,
help: false,
hooks: undefined,
json: false,
mode: undefined,
moveScope: false,
scope: undefined,
yes: false,
};
const valueFlags = new Map([
['--mode', 'mode'],
['--scope', 'scope'],
['--hooks', 'hooks'],
]);
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (valueFlags.has(argument)) {
const value = argv[index + 1];
if (!value || value.startsWith('--')) {
throw new Error(`Missing value for ${argument}`);
}
options[valueFlags.get(argument)] = value;
index += 1;
} else if (argument === '--yes' || argument === '-y') {
options.yes = true;
} else if (argument === '--dry-run') {
options.dryRun = true;
} else if (argument === '--move-scope') {
options.moveScope = true;
} else if (argument === '--json') {
options.json = true;
} else if (argument === '--help' || argument === '-h') {
options.help = true;
} else {
throw new Error(`Unknown argument: ${argument}`);
}
}
if (options.mode !== undefined && options.mode !== MODE) {
throw new Error(`Invalid setup mode: ${options.mode}. This command currently supports ${MODE}.`);
}
if (options.scope !== undefined && !VALID_SCOPES.has(options.scope)) {
throw new Error(`Invalid --scope value: ${options.scope}`);
}
if (options.hooks !== undefined && !VALID_HOOK_MODES.has(options.hooks)) {
throw new Error(`Invalid --hooks value: ${options.hooks}`);
}
if (options.moveScope && options.scope === undefined) {
throw new Error('--move-scope requires an explicit --scope destination.');
}
return options;
}
function questionWithCancellation(terminal, prompt) {
return new Promise((resolve, reject) => {
let settled = false;
const finish = callback => value => {
if (settled) return;
settled = true;
terminal.removeListener('close', onClose);
callback(value);
};
const onClose = finish(() => {
const error = new Error('Readline was closed before an answer was received.');
error.code = 'ABORT_ERR';
reject(error);
});
const resolveAnswer = finish(resolve);
const rejectQuestion = finish(reject);
terminal.once('close', onClose);
Promise.resolve(terminal.question(prompt)).then(resolveAnswer, rejectQuestion);
});
}
async function askChoice(terminal, prompt, choices, defaultIndex) {
process.stdout.write(`\n${prompt}\n`);
choices.forEach((choice, index) => {
process.stdout.write(` ${index + 1}. ${choice.label}${choice.description}\n`);
});
const choiceNumbers = choices.map((_, index) => String(index + 1));
const validChoices = choiceNumbers.length === 1
? choiceNumbers[0]
: `${choiceNumbers.slice(0, -1).join(', ')}, or ${choiceNumbers.at(-1)}`;
while (true) {
const hasDefault = Number.isInteger(defaultIndex);
const answer = await questionWithCancellation(
terminal,
hasDefault ? `Choose [${defaultIndex + 1}]: ` : 'Choose: '
);
const normalized = answer.trim().toLowerCase();
if (normalized === '' && hasDefault) return choices[defaultIndex].value;
const namedChoice = choices.find(choice => choice.value === normalized);
if (namedChoice) return namedChoice.value;
if (/^\d+$/.test(normalized)) {
const index = Number(normalized) - 1;
if (index >= 0 && index < choices.length) return choices[index].value;
}
process.stdout.write(`Please choose ${validChoices}.\n`);
}
}
function resolveInteractiveDefaults() {
try {
const result = setupClaudePlugin({ dryRun: true });
return {
hooks: result.hooks,
installed: result.action === 'would-update',
scope: result.scope,
};
} catch (error) {
if (!(error instanceof ClaudeSetupError)) throw error;
if (error.code === 'SCOPE_REQUIRED') {
return {
hooks: 'standard',
installed: false,
scope: 'user',
};
}
if (error.code === 'MULTIPLE_PLUGIN_SCOPES') {
const paths = resolveClaudePaths();
return {
hooks: deriveHookMode(readSettings(path.join(paths.configDir, 'settings.json'))),
installed: true,
multipleScopes: true,
scope: undefined,
};
}
throw error;
}
}
async function collectInteractiveOptions(options, defaults = {}, providedTerminal) {
const terminal = providedTerminal || readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const ownsTerminal = !providedTerminal;
try {
const scopeChoices = [
{
value: 'user',
label: 'Global user',
description: 'Available in every project for this user.',
},
{
value: 'project',
label: 'Shared project',
description: 'Stored in repository settings for collaborators.',
},
{
value: 'local',
label: 'Private project',
description: 'Enabled only here without committing the choice.',
},
];
const detectedScopeDefault = scopeChoices.findIndex(
choice => choice.value === defaults.scope
);
const scopeDefaultIndex = detectedScopeDefault === -1
? undefined
: detectedScopeDefault;
const scope = options.scope || await askChoice(
terminal,
'Where should Claude enable ecc@ecc?',
scopeChoices,
scopeDefaultIndex
);
const hookChoices = [
{
value: 'off',
label: 'Off',
description: 'Keep skills and commands without local hook automation.',
},
{
value: 'minimal',
label: 'Minimal',
description: 'Run only the lightest lifecycle and safety automation.',
},
{
value: 'standard',
label: 'Standard',
description: 'Balanced quality and safety automation.',
},
{
value: 'strict',
label: 'Strict',
description: 'Use the strongest checks and reminders.',
},
];
const detectedHookDefault = hookChoices.findIndex(
choice => choice.value === defaults.hooks
);
const hookDefaultIndex = detectedHookDefault === -1 ? 2 : detectedHookDefault;
const hooks = options.hooks || await askChoice(
terminal,
'How should ECC hooks run?',
hookChoices,
hookDefaultIndex
);
return {
...options,
hooks,
mode: MODE,
scope,
};
} finally {
if (ownsTerminal) terminal.close();
}
}
async function confirm(options, providedTerminal) {
const terminal = providedTerminal || readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const ownsTerminal = !providedTerminal;
try {
const operation = options.moveScope
? 'Migrate'
: (options.confirmationAction || 'Apply');
const scopeLabel = options.scope || 'the detected';
const answer = await questionWithCancellation(
terminal,
`${operation} ${MODE} setup at ${scopeLabel} scope`
+ ` with hooks=${options.hooks || 'standard'}? [y/N] `
);
return /^y(es)?$/i.test(answer.trim());
} finally {
if (ownsTerminal) terminal.close();
}
}
function printResult(result, json) {
if (json) {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
return;
}
process.stdout.write(`\nECC ${result.action} ${result.pluginId} at ${result.scope} scope.\n`);
if (result.sourceScope) {
process.stdout.write(`Previous scope: ${result.sourceScope}\n`);
}
process.stdout.write(`Hook preference: ${result.hooks}\n`);
if (result.restartRequired) {
process.stdout.write('Restart Claude Code or run /reload-plugins to load the updated plugin.\n');
}
}
function printError(error, json) {
if (json) {
const payload = error instanceof ClaudeSetupError
? error.toJSON()
: {
error: {
code: 'SETUP_FAILED',
message: error.message,
phase: 'cli',
observedScopes: [],
recovery: [],
},
};
process.stderr.write(`${JSON.stringify(payload, null, 2)}\n`);
return;
}
process.stderr.write(`Error: ${error.message}\n`);
}
function isInteractiveCancellation(error) {
return Boolean(error && (
error.code === 'ABORT_ERR'
|| /aborted with ctrl\+d|readline was closed/i.test(error.message || '')
));
}
function needsInteractiveChoices(options) {
return (
options.mode === undefined
|| options.scope === undefined
|| options.hooks === undefined
);
}
function validateInteractiveJsonOptions(options, interactive) {
if (!interactive || !options.json) return;
if (needsInteractiveChoices(options)) {
throw new Error(
'Interactive --json requires explicit --mode, --scope, and --hooks values.'
);
}
if (!options.yes && !options.dryRun) {
throw new Error('Interactive --json mutations require --yes.');
}
}
function reconcileClaudePlugin(options) {
const setupOptions = {
dryRun: options.dryRun,
hooks: options.hooks,
scope: options.scope,
};
if (options.moveScope) {
return migrateClaudePluginScope(setupOptions);
}
try {
return setupClaudePlugin(setupOptions);
} catch (error) {
const canAutoMigrate = (
error instanceof ClaudeSetupError
&& AUTO_MIGRATION_CODES.has(error.code)
&& options.scope !== undefined
);
if (!canAutoMigrate) throw error;
return migrateClaudePluginScope(setupOptions);
}
}
function applyClaudePlugin(options, interactive) {
const spinner = interactive && !options.dryRun && !options.json
? startTerminalSpinner('Applying ECC setup...')
: undefined;
try {
return reconcileClaudePlugin(options);
} finally {
spinner?.stop();
}
}
async function main(argv = process.argv.slice(2)) {
let options;
let terminal;
try {
options = parseArgs(argv);
if (options.help) {
showHelp();
return;
}
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
validateInteractiveJsonOptions(options, interactive);
const shouldCollectInteractiveChoices = needsInteractiveChoices(options);
const needsConfirmation = !options.yes && !options.dryRun;
const interactiveDefaults = interactive
&& (shouldCollectInteractiveChoices || needsConfirmation)
? resolveInteractiveDefaults()
: undefined;
if (interactive && shouldCollectInteractiveChoices) {
terminal = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
options = await collectInteractiveOptions(
options,
interactiveDefaults,
terminal
);
} else if (!options.mode) {
if (!interactive) {
throw new Error(
'Interactive setup requires a terminal. Pass --mode claude-plugin and the required flags.'
);
}
}
if (interactiveDefaults) {
const confirmationAction = interactiveDefaults.multipleScopes
? 'Resume migration'
: (
interactiveDefaults.installed && interactiveDefaults.scope !== options.scope
? 'Migrate'
: 'Apply'
);
options = {
...options,
confirmationAction,
};
}
if (needsConfirmation) {
if (!interactive) {
throw new Error('Non-interactive setup requires --yes.');
}
if (!terminal) {
terminal = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
}
if (!await confirm(options, terminal)) {
printResult({
action: 'cancelled',
hooks: options.hooks || 'standard',
pluginId: 'ecc@ecc',
scope: options.scope || 'detected',
}, options.json);
return;
}
}
const result = applyClaudePlugin(options, interactive);
printResult(result, options.json);
showTerminalWelcome({
action: result.action,
dryRun: options.dryRun,
interactive,
json: options.json,
});
} catch (error) {
if (isInteractiveCancellation(error)) {
process.stdout.write('\nECC setup cancelled. No changes were made.\n');
return;
}
printError(error, options?.json);
process.exitCode = 1;
} finally {
terminal?.close();
}
}
if (require.main === module) {
main();
}
module.exports = {
collectInteractiveOptions,
applyClaudePlugin,
main,
parseArgs,
printError,
printResult,
questionWithCancellation,
reconcileClaudePlugin,
resolveInteractiveDefaults,
isInteractiveCancellation,
validateInteractiveJsonOptions,
showHelp,
};
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env node
'use strict';
const {
ECC_VERSION_PATTERN,
renderTerminalWelcome,
} = require('./lib/terminal-welcome');
const VALID_ACTIONS = new Set([
'installed',
'updated',
'configured',
'migrated',
'resumed',
'already-migrated',
]);
function parseArgs(argv) {
let action = 'installed';
let version;
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === '--action') {
const value = argv[index + 1];
if (!value || value.startsWith('--')) {
throw new Error('Missing value for --action');
}
action = value;
index += 1;
} else if (argument === '--version') {
const value = argv[index + 1];
if (!value || value.startsWith('--')) {
throw new Error('Missing value for --version');
}
version = value;
index += 1;
} else {
throw new Error('Unknown argument');
}
}
if (!VALID_ACTIONS.has(action)) {
throw new Error('Invalid --action value');
}
if (version !== undefined && !ECC_VERSION_PATTERN.test(version)) {
throw new Error('Invalid --version value');
}
return { action, version };
}
function main(argv = process.argv.slice(2)) {
try {
const { action, version } = parseArgs(argv);
const color = process.env.NO_COLOR === undefined
&& process.env.TERM !== 'dumb'
&& Boolean(process.stdout.isTTY);
process.stdout.write(renderTerminalWelcome({ action, color, version }));
} catch (error) {
process.stderr.write(`Error: ${error.message}\n`);
process.exitCode = 1;
}
}
if (require.main === module) {
main();
}
module.exports = { main, parseArgs };
+158 -337
View File
@@ -1,385 +1,206 @@
---
name: configure-ecc
description: Interactive installer for Everything Claude Code — guides users through selecting and installing skills and rules to user-level or project-level directories, verifies paths, and optionally optimizes installed files.
description: Guide ECC installation, update, or reconfiguration from inside Claude Code, Codex, or Kimi while respecting each harness's real plugin, scope, and hook capabilities.
metadata:
origin: ECC
---
# Configure Everything Claude Code (ECC)
# Configure Everything Claude Code
An interactive, step-by-step installation wizard for the Everything Claude Code project. Uses `AskUserQuestion` to guide users through selective installation of skills and rules, then verifies correctness and offers optimization.
Run a conversational wizard inside the current harness. Inventory first, collect
only supported choices, preview, confirm once, apply non-interactively, verify,
and show the welcome only after success. Never clone ECC into a temporary
directory or copy plugin components by hand.
## When to Activate
For a human-operated terminal, the canonical entry points are `ecc setup` and
`npx ecc-universal setup`. Inside a harness, use the explicit non-interactive
commands below instead.
- User says "configure ecc", "install ecc", "setup everything claude code", or similar
- User wants to selectively install skills or rules from this project
- User wants to verify or fix an existing ECC installation
- User wants to optimize installed skills or rules for their project
## Route by the current harness
## Prerequisites
- In Claude Code, use the full scope-and-hook wizard below.
- In Codex, use Codex's native plugin lifecycle. Do not offer Claude scopes or
map ECC's four Claude hook profiles onto Codex.
- In Kimi, install the project surface under `./.kimi-code`. Kimi does not
provide ECC's Claude lifecycle-hook profiles.
- If the harness is uncertain, state the detected evidence and ask which
harness to configure before running a mutating command.
This skill must be accessible to Claude Code before activation. Two ways to bootstrap:
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"
This skill is a post-install reconfiguration path. It cannot intercept or
replace a provider's built-in first-install UI.
---
## Claude Code: run the full conversational wizard
## Step 0: Clone ECC Repository
### 1. Inventory without changing anything
Before any installation, clone the latest ECC source to `/tmp`:
Run both commands and summarize the installed ECC scope, enabled state, and
marketplace source:
```bash
rm -rf /tmp/everything-claude-code
git clone https://github.com/affaan-m/everything-claude-code.git /tmp/everything-claude-code
claude plugin list --json
claude plugin marketplace list --json
```
Set `ECC_ROOT=/tmp/everything-claude-code` as the source for all subsequent copy operations.
Treat a single existing `ecc@ecc` installation as a reconfiguration. Do not
interpret Claude's provider-owned "Open home page" control as installation
evidence. Stop and report the recovery returned by setup for multiple ECC
scopes, a legacy/manual install, malformed settings, or a marketplace collision;
never guess which state to delete.
If the clone fails (network issues, etc.), use `AskUserQuestion` to ask the user to provide a local path to an existing ECC clone.
### 2. Collect exactly two choices
---
Ask exactly one scope question and require one value:
## Step 1: Choose Installation Level
- `user | project | local`
- `user` is global for this user.
- `project` is shared through repository settings.
- `local` is private to the current project.
Use `AskUserQuestion` to ask the user where to install:
Visually mark only the selected scope as selected or installing. If the user
chooses a different scope from a single existing install, describe it as a
scope migration and include `--move-scope` in the commands below.
```
Question: "Where should ECC components be installed?"
Options:
- "User-level (~/.claude/)" — "Applies to all your Claude Code projects"
- "Project-level (.claude/)" — "Applies only to the current project"
- "Both" — "Common/shared items user-level, project-specific items project-level"
```
Ask exactly one hook-mode question and require one value:
Store the choice as `INSTALL_LEVEL`. Set the target directory:
- User-level: `TARGET=~/.claude`
- Project-level: `TARGET=.claude` (relative to current project root)
- Both: `TARGET_USER=~/.claude`, `TARGET_PROJECT=.claude`
- `off | minimal | standard | strict`
- `off` keeps skills and commands but disables ECC hook automation.
- `minimal` enables the lightest lifecycle and safety automation.
- `standard` balances quality and safety automation.
- `strict` enables the strongest checks and reminders.
Create the target directories if they don't exist:
```bash
mkdir -p $TARGET/skills $TARGET/rules
```
Hook preference is personal Claude plugin configuration; it does not follow
the selected install scope.
---
### 3. Preview and confirm once
## Step 2: Select & Install Skills
### 2a: Choose Scope (Core vs Niche)
Default to **Core (recommended for new users)** — copy `.agents/skills/*` plus `skills/search-first/` for research-first workflows. This bundle covers engineering, evals, verification, security, strategic compaction, frontend design, and Anthropic cross-functional skills (article-writing, content-engine, market-research, frontend-slides).
Use `AskUserQuestion` (single select):
```
Question: "Install core skills only, or include niche/framework packs?"
Options:
- "Core only (recommended)" — "tdd, e2e, evals, verification, research-first, security, frontend patterns, compacting, cross-functional Anthropic skills"
- "Core + selected niche" — "Add framework/domain-specific skills after core"
- "Niche only" — "Skip core, install specific framework/domain skills"
Default: Core only
```
If the user chooses niche or core + niche, continue to category selection below and only include those niche skills they pick.
### 2b: Choose Skill Categories
There are 7 selectable category groups below. The detailed confirmation lists that follow cover 45 skills across 8 categories, plus 1 standalone template. Use `AskUserQuestion` with `multiSelect: true`:
```
Question: "Which skill categories do you want to install?"
Options:
- "Framework & Language" — "Django, Laravel, Spring Boot, Quarkus, Go, Python, Java, Frontend, Backend patterns"
- "Database" — "PostgreSQL, ClickHouse, JPA/Hibernate patterns"
- "Workflow & Quality" — "TDD, verification, learning, security review, compaction"
- "Research & APIs" — "Deep research, Exa search, Claude API patterns"
- "Social & Content Distribution" — "X/Twitter API, crossposting alongside content-engine"
- "Media Generation" — "fal.ai image/video/audio alongside VideoDB"
- "Orchestration" — "dmux multi-agent workflows"
- "All skills" — "Install every available skill"
```
### 2c: Confirm Individual Skills
For each selected category, print the full list of skills below and ask the user to confirm or deselect specific ones. If the list exceeds 4 items, print the list as text and use `AskUserQuestion` with an "Install all listed" option plus "Other" for the user to paste specific names.
**Category: Framework & Language (25 skills)**
| Skill | Description |
|-------|-------------|
| `backend-patterns` | Backend architecture, API design, server-side best practices for Node.js/Express/Next.js |
| `coding-standards` | Universal coding standards for TypeScript, JavaScript, React, Node.js |
| `django-patterns` | Django architecture, REST API with DRF, ORM, caching, signals, middleware |
| `django-security` | Django security: auth, CSRF, SQL injection, XSS prevention |
| `django-tdd` | Django testing with pytest-django, factory_boy, mocking, coverage |
| `django-verification` | Django verification loop: migrations, linting, tests, security scans |
| `laravel-patterns` | Laravel architecture patterns: routing, controllers, Eloquent, queues, caching |
| `laravel-security` | Laravel security: auth, policies, CSRF, mass assignment, rate limiting |
| `laravel-tdd` | Laravel testing with PHPUnit and Pest, factories, fakes, coverage |
| `laravel-verification` | Laravel verification: linting, static analysis, tests, security scans |
| `frontend-patterns` | React, Next.js, state management, performance, UI patterns |
| `frontend-slides` | Zero-dependency HTML presentations, style previews, and PPTX-to-web conversion |
| `golang-patterns` | Idiomatic Go patterns, conventions for robust Go applications |
| `golang-testing` | Go testing: table-driven tests, subtests, benchmarks, fuzzing |
| `java-coding-standards` | Java coding standards for Spring Boot and Quarkus: naming, immutability, Optional, streams, CDI |
| `python-patterns` | Pythonic idioms, PEP 8, type hints, best practices |
| `python-testing` | Python testing with pytest, TDD, fixtures, mocking, parametrization |
| `quarkus-patterns` | Quarkus architecture, Camel messaging, CDI services, Panache data access |
| `quarkus-security` | Quarkus security: JWT/OIDC, RBAC, input validation, secrets management |
| `quarkus-tdd` | Quarkus TDD with JUnit 5, Mockito, REST Assured, Camel testing |
| `quarkus-verification` | Quarkus verification: build, static analysis, tests, native compilation |
| `springboot-patterns` | Spring Boot architecture, REST API, layered services, caching, async |
| `springboot-security` | Spring Security: authn/authz, validation, CSRF, secrets, rate limiting |
| `springboot-tdd` | Spring Boot TDD with JUnit 5, Mockito, MockMvc, Testcontainers |
| `springboot-verification` | Spring Boot verification: build, static analysis, tests, security scans |
**Category: Database (3 skills)**
| Skill | Description |
|-------|-------------|
| `clickhouse-io` | ClickHouse patterns, query optimization, analytics, data engineering |
| `jpa-patterns` | JPA/Hibernate entity design, relationships, query optimization, transactions |
| `postgres-patterns` | PostgreSQL query optimization, schema design, indexing, security |
**Category: Workflow & Quality (8 skills)**
| Skill | Description |
|-------|-------------|
| `continuous-learning` | Legacy v1 Stop-hook session pattern extraction; prefer `continuous-learning-v2` for new installs |
| `continuous-learning-v2` | Instinct-based learning with confidence scoring, evolves into skills, agents, and optional legacy command shims |
| `eval-harness` | Formal evaluation framework for eval-driven development (EDD) |
| `iterative-retrieval` | Progressive context refinement for subagent context problem |
| `security-review` | Security checklist: auth, input, secrets, API, payment features |
| `strategic-compact` | Suggests manual context compaction at logical intervals |
| `tdd-workflow` | Enforces TDD with 80%+ coverage: unit, integration, E2E |
| `verification-loop` | Verification and quality loop patterns |
**Category: Business & Content (5 skills)**
| Skill | Description |
|-------|-------------|
| `article-writing` | Long-form writing in a supplied voice using notes, examples, or source docs |
| `content-engine` | Multi-platform social content, scripts, and repurposing workflows |
| `market-research` | Source-attributed market, competitor, fund, and technology research |
| `investor-materials` | Pitch decks, one-pagers, investor memos, and financial models |
| `investor-outreach` | Personalized investor cold emails, warm intros, and follow-ups |
**Category: Research & APIs (2 skills)**
| Skill | Description |
|-------|-------------|
| `deep-research` | Multi-source deep research using firecrawl and exa MCPs with cited reports |
| `exa-search` | Neural search via Exa MCP for web, code, company, and people research |
`claude-api` is an Anthropic canonical skill. Install it from [`anthropics/skills`](https://github.com/anthropics/skills) when you want the official Claude API workflow instead of an ECC-bundled copy.
**Category: Social & Content Distribution (2 skills)**
| Skill | Description |
|-------|-------------|
| `x-api` | X/Twitter API integration for posting, threads, search, and analytics |
| `crosspost` | Multi-platform content distribution with platform-native adaptation |
**Category: Media Generation (2 skills)**
| Skill | Description |
|-------|-------------|
| `fal-ai-media` | Unified AI media generation (image, video, audio) via fal.ai MCP |
| `video-editing` | AI-assisted video editing for cutting, structuring, and augmenting real footage |
**Category: Orchestration (1 skill)**
| Skill | Description |
|-------|-------------|
| `dmux-workflows` | Multi-agent orchestration using dmux for parallel agent sessions |
**Standalone**
| Skill | Description |
|-------|-------------|
| `docs/examples/project-guidelines-template.md` | Template for creating project-specific skills |
### 2d: Execute Installation
For each selected skill, copy the entire skill directory from the correct source root:
Prefer the plugin-bundled setup script. Substitute the two selected values and
include `--move-scope` only for a scope migration:
```bash
# Core skills live under .agents/skills/
cp -R "$ECC_ROOT/.agents/skills/<skill-name>" "$TARGET/skills/"
# Niche skills live under skills/
cp -R "$ECC_ROOT/skills/<skill-name>" "$TARGET/skills/"
node "$CLAUDE_PLUGIN_ROOT/scripts/setup.js" --mode claude-plugin \
--scope <scope> --hooks <hooks> [--move-scope] --dry-run --json
```
When iterating over globbed source directories, never pass a trailing-slash source directly to `cp`. Use the directory path as the destination name explicitly:
If `$CLAUDE_PLUGIN_ROOT` is unavailable, use the published npm package:
```bash
cp -R "${src%/}" "$TARGET/skills/$(basename "${src%/}")"
npx --yes --package ecc-universal ecc setup --mode claude-plugin \
--scope <scope> --hooks <hooks> [--move-scope] --dry-run --json
```
Note: `continuous-learning` and `continuous-learning-v2` have extra files (config.json, hooks, scripts) — ensure the entire directory is copied, not just SKILL.md.
Show exactly one confirmation summary containing the planned action, one scope,
one hook mode, marketplace action, and any source-to-destination migration.
Ask one yes/no question. Do not run a bare interactive `ecc setup` through a
harness shell tool because that shell is commonly non-TTY.
---
### 4. Apply the explicit choices
## Step 3: Select & Install Rules
Use `AskUserQuestion` with `multiSelect: true`:
```
Question: "Which rule sets do you want to install?"
Options:
- "Common rules (Recommended)" — "Language-agnostic principles: coding style, git workflow, testing, security, etc. (8 files)"
- "TypeScript/JavaScript" — "TS/JS patterns, hooks, testing with Playwright (5 files)"
- "Python" — "Python patterns, pytest, black/ruff formatting (5 files)"
- "Go" — "Go patterns, table-driven tests, gofmt/staticcheck (5 files)"
```
Execute installation:
```bash
# Common rules
cp -r $ECC_ROOT/rules/common $TARGET/rules/common
# Language-specific rules (preserve per-language directories)
cp -r $ECC_ROOT/rules/typescript $TARGET/rules/typescript # if selected
cp -r $ECC_ROOT/rules/python $TARGET/rules/python # if selected
cp -r $ECC_ROOT/rules/golang $TARGET/rules/golang # if selected
```
**Important**: If the user selects any language-specific rules but NOT common rules, warn them:
> "Language-specific rules extend the common rules. Installing without common rules may result in incomplete coverage. Install common rules too?"
---
## Step 4: Post-Installation Verification
After installation, perform these automated checks:
### 4a: Verify File Existence
List all installed files and confirm they exist at the target location:
```bash
ls -la $TARGET/skills/
ls -la $TARGET/rules/
```
### 4b: Check Path References
Scan all installed `.md` files for path references:
```bash
grep -rn "~/.claude/" $TARGET/skills/ $TARGET/rules/
grep -rn "../common/" $TARGET/rules/
grep -rn "skills/" $TARGET/skills/
```
**For project-level installs**, flag any references to `~/.claude/` paths:
- If a skill references `~/.claude/settings.json` — this is usually fine (settings are always user-level)
- If a skill references `~/.claude/skills/` or `~/.claude/rules/` — this may be broken if installed only at project level
- If a skill references another skill by name — check that the referenced skill was also installed
### 4c: Check Cross-References Between Skills
Some skills reference others. Verify these dependencies:
- `django-tdd` may reference `django-patterns`
- `laravel-tdd` may reference `laravel-patterns`
- `quarkus-tdd` may reference `quarkus-patterns`
- `springboot-tdd` may reference `springboot-patterns`
- `continuous-learning-v2` references `~/.claude/homunculus/` directory
- `python-testing` may reference `python-patterns`
- `golang-testing` may reference `golang-patterns`
- `crosspost` references `content-engine` and `x-api`
- `deep-research` references `exa-search` (complementary MCP tools)
- `fal-ai-media` references `videodb` (complementary media skill)
- `x-api` references `content-engine` and `crosspost`
- Language-specific rules reference `common/` counterparts
### 4d: Report Issues
For each issue found, report:
1. **File**: The file containing the problematic reference
2. **Line**: The line number
3. **Issue**: What's wrong (e.g., "references ~/.claude/skills/python-patterns but python-patterns was not installed")
4. **Suggested fix**: What to do (e.g., "install python-patterns skill" or "update path to .claude/skills/")
---
## Step 5: Optimize Installed Files (Optional)
Use `AskUserQuestion`:
```
Question: "Would you like to optimize the installed files for your project?"
Options:
- "Optimize skills" — "Remove irrelevant sections, adjust paths, tailor to your tech stack"
- "Optimize rules" — "Adjust coverage targets, add project-specific patterns, customize tool configs"
- "Optimize both" — "Full optimization of all installed files"
- "Skip" — "Keep everything as-is"
```
### If optimizing skills:
1. Read each installed SKILL.md
2. Ask the user what their project's tech stack is (if not already known)
3. For each skill, suggest removals of irrelevant sections
4. Edit the SKILL.md files in-place at the installation target (NOT the source repo)
5. Fix any path issues found in Step 4
### If optimizing rules:
1. Read each installed rule .md file
2. Ask the user about their preferences:
- Test coverage target (default 80%)
- Preferred formatting tools
- Git workflow conventions
- Security requirements
3. Edit the rule files in-place at the installation target
**Critical**: Only modify files in the installation target (`$TARGET/`), NEVER modify files in the source ECC repository (`$ECC_ROOT/`).
---
## Step 6: Installation Summary
Clean up the cloned repository from `/tmp`:
After confirmation, rerun the same route without `--dry-run`. Keep every choice
explicit and request JSON so success can be checked deterministically:
```bash
rm -rf /tmp/everything-claude-code
node "$CLAUDE_PLUGIN_ROOT/scripts/setup.js" --mode claude-plugin \
--scope <scope> --hooks <hooks> [--move-scope] --yes --json
```
Then print a summary report:
Fallback:
```
## ECC Installation Complete
### Installation Target
- Level: [user-level / project-level / both]
- Path: [target path]
### Skills Installed ([count])
- skill-1, skill-2, skill-3, ...
### Rules Installed ([count])
- common (8 files)
- typescript (5 files)
- ...
### Verification Results
- [count] issues found, [count] fixed
- [list any remaining issues]
### Optimizations Applied
- [list changes made, or "None"]
```bash
npx --yes --package ecc-universal ecc setup --mode claude-plugin \
--scope <scope> --hooks <hooks> [--move-scope] --yes --json
```
---
### 5. Verify, then render the welcome
## Troubleshooting
Require a zero exit status and a setup result whose `scope` and `hooks` equal
the selected values. Then independently run:
### "Skills not being picked up by Claude Code"
- Verify the skill directory contains a `SKILL.md` file (not just loose .md files)
- For user-level: check `~/.claude/skills/<skill-name>/SKILL.md` exists
- For project-level: check `.claude/skills/<skill-name>/SKILL.md` exists
```bash
claude plugin list --json
```
### "Rules not working"
- Rules are flat files, not in subdirectories: `$TARGET/rules/coding-style.md` (correct) vs `$TARGET/rules/common/coding-style.md` (incorrect for flat install)
- Restart Claude Code after installing rules
Continue only when exactly one enabled `ecc@ecc` entry exists at the selected
scope. When `$CLAUDE_PLUGIN_ROOT` is available, pass the successful setup
`action` (`installed`, `updated`, `migrated`, `resumed`, or
`already-migrated`) to the bundled renderer:
### "Path reference errors after project-level install"
- Some skills assume `~/.claude/` paths. Run Step 4 verification to find and fix these.
- For `continuous-learning-v2`, the `~/.claude/homunculus/` directory is always user-level — this is expected and not an error.
Before invoking it, require the provider-reported version to match
`ECC_VERSION_PATTERN` from `scripts/lib/terminal-welcome.js`. Reject unexpected
version text instead of interpolating it into a shell command.
```bash
node -e 'const { renderTerminalWelcome } = require(process.env.CLAUDE_PLUGIN_ROOT + "/scripts/lib/terminal-welcome"); process.stdout.write(renderTerminalWelcome({ action: process.argv[1], version: process.argv[2], color: process.stdout.isTTY }));' "<action>" "<installed-version>"
```
Render the welcome exactly once. On failure, dry-run, cancellation, a scope or
hook mismatch, or unverifiable state, do not render it; report the error and
recovery instead. After verified changes, tell the user to run
`/reload-plugins` or restart Claude Code.
## Codex: use the native plugin lifecycle
Inventory with `codex plugin marketplace list --json` and
`codex plugin list --available --json`. Codex's native plugin command has no
Claude-style `user | project | local` selector. Codex native plugins do support
provider-specific hooks, but Codex requires explicit trust for them. Let Codex
show that trust decision; do not ask the Claude four-profile hook question or
claim those profiles map to Codex.
If the ECC marketplace is missing, add it. Otherwise refresh its snapshot:
```bash
codex plugin marketplace add affaan-m/ECC
codex plugin marketplace upgrade ecc --json
```
Ask for one confirmation, then install or idempotently refresh the installed
cache and verify it:
```bash
codex plugin add ecc@ecc --json
codex plugin list --json
```
Continue only when the JSON reports ECC installed and provides its
`installedPath`. Then render the verified bundle's welcome:
Use only the exact absolute `installedPath` returned by Codex JSON. Reject
control characters and require the installed version to match
`ECC_VERSION_PATTERN`. Invoke `node` directly with this argument array; this is
a tool API invocation, not a shell command:
```text
["<installedPath>/scripts/welcome.js", "--action", "configured", "--version", "<installed-version>"]
```
If the current harness cannot invoke an executable with a separate argument
array, skip the welcome. Never construct a shell command from Codex JSON values.
Never claim that Claude's `off | minimal | standard | strict` profiles were
applied to Codex.
## Kimi: install the project surface
State the capability summary before confirmation: destination
`./.kimi-code`; `hooks=unsupported` for ECC lifecycle hooks. Do not ask the
Claude scope or hook-mode questions. Preview first:
```bash
npx --yes --package ecc-universal ecc install --profile core --target kimi --dry-run
```
Show one confirmation for that project destination, then apply the identical
command without `--dry-run`. Verify with:
```bash
npx --yes --package ecc-universal ecc doctor --target kimi
```
Only after doctor succeeds and the installed instructions and skills remain
inside `./.kimi-code`, render:
```bash
npx --yes --package ecc-universal ecc welcome --action configured
```
Do not claim that Kimi installed or configured ECC lifecycle hooks.
+94
View File
@@ -0,0 +1,94 @@
/**
* Integration checks for the native Codex plugin hook boundary.
*
* Run with: node tests/codex-native-hooks.test.js
*/
'use strict';
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const repoRoot = path.resolve(__dirname, '..');
const hookConfig = JSON.parse(fs.readFileSync(path.join(repoRoot, 'hooks', 'codex-hooks.json'), 'utf8'));
const sessionStart = hookConfig.hooks.SessionStart[0].hooks[0];
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(`${name}`);
passed++;
} catch (error) {
console.log(`${name}`);
console.log(` Error: ${error.message}`);
failed++;
}
}
function runSessionStart({ pluginRoot }) {
const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-codex-hook-'));
const userHome = path.join(fixtureRoot, 'user-home');
const projectDir = path.join(fixtureRoot, 'project');
const pluginData = path.join(fixtureRoot, 'plugin-data');
fs.mkdirSync(userHome, { recursive: true });
fs.mkdirSync(projectDir, { recursive: true });
fs.mkdirSync(pluginData, { recursive: true });
const env = {
...process.env,
HOME: userHome,
USERPROFILE: userHome,
PLUGIN_DATA: pluginData
};
delete env.CLAUDE_PLUGIN_ROOT;
if (pluginRoot) {
env.PLUGIN_ROOT = pluginRoot;
} else {
delete env.PLUGIN_ROOT;
}
const input = JSON.stringify({
session_id: 'codex-native-hook-test',
transcript_path: path.join(fixtureRoot, 'transcript.jsonl'),
cwd: projectDir,
hook_event_name: 'SessionStart',
source: 'startup'
});
try {
return spawnSync(sessionStart.command, {
cwd: projectDir,
env,
input,
encoding: 'utf8',
shell: true,
timeout: 15_000
});
} finally {
fs.rmSync(fixtureRoot, { recursive: true, force: true });
}
}
test('installed Codex SessionStart hook resolves from PLUGIN_ROOT and emits Codex output', () => {
const result = runSessionStart({ pluginRoot: repoRoot });
assert.strictEqual(result.status, 0, result.stderr || result.error?.message);
const output = JSON.parse(result.stdout);
assert.strictEqual(output.hookSpecificOutput.hookEventName, 'SessionStart');
assert.strictEqual(typeof output.hookSpecificOutput.additionalContext, 'string');
});
test('Codex SessionStart hook fails closed when PLUGIN_ROOT is absent', () => {
const result = runSessionStart({ pluginRoot: null });
assert.notStrictEqual(result.status, 0, 'Hook must not fall through to a stale ~/.claude plugin');
assert.match(result.stderr, /Missing Codex PLUGIN_ROOT/);
});
console.log(`\nPassed: ${passed}`);
console.log(`Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
+119 -14
View File
@@ -12,6 +12,24 @@ const configureEccDocs = [
'docs/ja-JP/skills/configure-ecc/SKILL.md',
];
const localizedWizardContract = {
'skills/configure-ecc/SKILL.md': [
'Ask exactly one scope question',
'Ask exactly one hook-mode question',
'Show exactly one confirmation summary',
],
'docs/zh-CN/skills/configure-ecc/SKILL.md': [
'只询问一次安装范围',
'只询问一次 Hook 模式',
'只显示一次确认摘要',
],
'docs/ja-JP/skills/configure-ecc/SKILL.md': [
'スコープについて 1 回だけ質問',
'フックモードについて 1 回だけ質問',
'確認サマリーは 1 回だけ表示',
],
};
let passed = 0;
let failed = 0;
@@ -31,36 +49,123 @@ function readConfigureEccDoc(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
}
function countEntries(relativePath, predicate) {
return fs.readdirSync(path.join(repoRoot, relativePath), { withFileTypes: true })
.filter(predicate)
.length;
}
console.log('\n=== Testing configure-ecc install path guidance ===\n');
for (const relativePath of configureEccDocs) {
test(`${relativePath} separates core and niche skill source roots`, () => {
test(`${relativePath} delegates to guided plugin setup`, () => {
const content = readConfigureEccDoc(relativePath);
assert.ok(
content.includes('$ECC_ROOT/.agents/skills/<skill-name>'),
'Expected configure-ecc to document the core skill source root'
);
assert.ok(
content.includes('$ECC_ROOT/skills/<skill-name>'),
'Expected configure-ecc to document the niche skill source root'
);
assert.ok(content.includes('ecc setup'));
assert.ok(content.includes('npx ecc-universal setup'));
assert.ok(content.includes('--mode claude-plugin'));
assert.ok(content.includes('--scope <scope>'));
assert.ok(content.includes('--hooks <hooks>'));
assert.ok(content.includes('--move-scope'));
assert.ok(!content.includes('rm -rf /tmp/everything-claude-code'));
assert.ok(!content.includes('cp -R "$ECC_ROOT'));
});
test(`${relativePath} documents defensive copy form for trailing slash sources`, () => {
test(`${relativePath} defines the Claude in-harness wizard contract`, () => {
const content = readConfigureEccDoc(relativePath);
for (const instruction of localizedWizardContract[relativePath]) {
assert.ok(content.includes(instruction), `missing: ${instruction}`);
}
assert.ok(content.includes('claude plugin list --json'));
assert.ok(content.includes('user | project | local'));
assert.ok(content.includes('off | minimal | standard | strict'));
assert.ok(content.includes('$CLAUDE_PLUGIN_ROOT'));
assert.ok(content.includes('scripts/setup.js'));
assert.ok(content.includes('--yes --json'));
assert.ok(content.includes('<installed-version>'));
assert.ok(content.includes('ECC_VERSION_PATTERN'));
assert.ok(content.includes('argument array'));
});
test(`${relativePath} verifies before showing the welcome`, () => {
const content = readConfigureEccDoc(relativePath);
const applyIndex = content.indexOf('--yes --json');
const verificationIndex = content.indexOf('claude plugin list --json', applyIndex);
const welcomeIndex = content.indexOf('renderTerminalWelcome');
assert.ok(applyIndex > -1, 'missing non-interactive apply command');
assert.ok(verificationIndex > -1, 'missing post-setup plugin verification');
assert.ok(welcomeIndex > verificationIndex, 'welcome must follow verification');
});
test(`${relativePath} keeps provider capabilities truthful`, () => {
const content = readConfigureEccDoc(relativePath);
assert.ok(content.includes('codex plugin add ecc@ecc --json'));
assert.ok(content.includes('Codex'));
assert.ok(content.includes('.kimi-code'));
assert.ok(content.includes('--target kimi'));
assert.ok(content.includes('hooks=unsupported'));
});
test(`${relativePath} verifies Codex and Kimi before their concrete welcomes`, () => {
const content = readConfigureEccDoc(relativePath);
const codexVerifyIndex = content.indexOf('codex plugin list --json');
const codexWelcomeIndex = content.indexOf(
'["<installedPath>/scripts/welcome.js", "--action", "configured", "--version", "<installed-version>"]'
);
const kimiVerifyIndex = content.indexOf('ecc doctor --target kimi');
const kimiWelcomeIndex = content.indexOf('ecc welcome --action configured');
assert.ok(codexVerifyIndex > -1, 'missing Codex verification');
assert.ok(codexWelcomeIndex > codexVerifyIndex, 'Codex welcome must follow verification');
assert.ok(
content.includes('${src%/}'),
'Expected configure-ecc to strip trailing slash before copying'
content.includes('argument array'),
'Codex welcome must use an executable plus argument array'
);
assert.ok(
content.includes('$(basename "${src%/}")'),
'Expected configure-ecc to preserve the skill directory name explicitly'
!content.includes('node "<installedPath>/scripts/welcome.js"'),
'Codex JSON values must not be shown in a shell command'
);
assert.ok(kimiVerifyIndex > -1, 'missing Kimi verification');
assert.ok(kimiWelcomeIndex > kimiVerifyIndex, 'Kimi welcome must follow verification');
});
}
test('Codex legacy sync docs do not require an unrelated package install', () => {
const content = readConfigureEccDoc('.codex-plugin/README.md');
assert.ok(content.includes('bash scripts/sync-ecc-to-codex.sh'));
assert.ok(!content.includes('npm install && bash scripts/sync-ecc-to-codex.sh'));
});
test('Kimi docs scope hooks and compatibility to the verified adapter', () => {
const content = readConfigureEccDoc('.kimi/README.md');
assert.ok(content.includes('verified against Kimi Code 0.31.x'));
assert.ok(content.includes("newer provider releases are outside this adapter's verified range"));
assert.ok(content.includes('does not configure or map provider lifecycle hooks'));
assert.ok(!content.includes('Kimi Code 0.31.x does not expose'));
});
test('Turkish agent instructions report the live catalog counts', () => {
const content = readConfigureEccDoc('docs/tr/AGENTS.md');
const agentCount = countEntries('agents', entry => entry.isFile() && entry.name.endsWith('.md'));
const skillCount = countEntries('skills', entry => entry.isDirectory());
const commandCount = countEntries(
'commands',
entry => entry.isFile() && entry.name.endsWith('.md')
);
assert.ok(content.includes(`${agentCount} özel agent`));
assert.ok(content.includes(`${skillCount} skill`));
assert.ok(content.includes(`${commandCount} command`));
assert.ok(content.includes(`agents/ — ${agentCount} özel subagent`));
assert.ok(content.includes(`skills/ — ${skillCount} iş akışı`));
assert.ok(content.includes(`commands/ — ${commandCount} slash command`));
});
if (failed > 0) {
console.log(`\nFailed: ${failed}`);
process.exit(1);
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env node
'use strict';
/**
* Stateful Claude plugin CLI fake.
*
* Environment:
* - ECC_TEST_CLAUDE_STATE: JSON state file (required)
* - ECC_TEST_CLAUDE_CALLS: JSONL argv log (optional)
*
* State supports:
* {
* plugins: [{ id, scope, enabled }],
* marketplaces: [{ name, source, repo, scope }],
* pluginListResponses: [array | string],
* marketplaceListResponses: [array | string],
* failures: [{ argv: [...], status, stderr, times }]
* }
*/
const fs = require('fs');
const args = process.argv.slice(2);
const statePath = process.env.ECC_TEST_CLAUDE_STATE;
const callsPath = process.env.ECC_TEST_CLAUDE_CALLS;
if (!statePath) {
process.stderr.write('ECC_TEST_CLAUDE_STATE is required\n');
process.exit(2);
}
if (callsPath) {
fs.appendFileSync(callsPath, `${JSON.stringify(args)}\n`);
}
function readState() {
return JSON.parse(fs.readFileSync(statePath, 'utf8'));
}
function writeState(state) {
fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`);
}
function sameArgv(left, right) {
return (
Array.isArray(left)
&& left.length === right.length
&& left.every((value, index) => value === right[index])
);
}
function shiftResponse(state, key, fallback) {
const queue = Array.isArray(state[key]) ? [...state[key]] : [];
if (queue.length === 0) return fallback;
const response = queue.shift();
writeState({ ...state, [key]: queue });
return response;
}
function printJsonResponse(response) {
process.stdout.write(typeof response === 'string' ? response : JSON.stringify(response));
}
let state = readState();
const failureIndex = (state.failures || []).findIndex(rule => (
sameArgv(rule.argv, args) && (rule.times === undefined || rule.times > 0)
));
if (failureIndex >= 0) {
const failure = state.failures[failureIndex];
const nextFailures = state.failures.map((rule, index) => (
index === failureIndex && Number.isInteger(rule.times)
? { ...rule, times: Math.max(0, rule.times - 1) }
: rule
));
writeState({ ...state, failures: nextFailures });
process.stderr.write(failure.stderr || 'injected Claude CLI failure\n');
process.exit(Number.isInteger(failure.status) ? failure.status : 1);
}
const joined = args.join(' ');
if (joined === 'plugin list --json') {
printJsonResponse(shiftResponse(state, 'pluginListResponses', state.plugins || []));
process.exit(0);
}
if (joined === 'plugin marketplace list --json') {
printJsonResponse(
shiftResponse(state, 'marketplaceListResponses', state.marketplaces || [])
);
process.exit(0);
}
if (args[0] === 'plugin' && args[1] === 'marketplace' && args[2] === 'add') {
const source = args[3];
const scopeIndex = args.indexOf('--scope');
const scope = scopeIndex >= 0 ? args[scopeIndex + 1] : 'user';
const marketplaces = [
...(state.marketplaces || []).filter(entry => entry.name !== 'ecc'),
{
name: 'ecc',
source: 'github',
repo: 'affaan-m/ECC',
url: source,
scope,
},
];
writeState({ ...state, marketplaces });
process.exit(0);
}
if (args[0] === 'plugin' && args[1] === 'marketplace' && args[2] === 'update') {
process.exit(0);
}
if (args[0] === 'plugin' && args[1] === 'install' && args[2] === 'ecc@ecc') {
const scopeIndex = args.indexOf('--scope');
const scope = scopeIndex >= 0 ? args[scopeIndex + 1] : 'user';
const plugins = [
...(state.plugins || []).filter(plugin => (
plugin.id !== 'ecc@ecc' || plugin.scope !== scope
)),
{ id: 'ecc@ecc', scope, enabled: true, version: '2.0.0' },
];
writeState({ ...state, plugins });
process.exit(0);
}
if (args[0] === 'plugin' && args[1] === 'update' && args[2] === 'ecc@ecc') {
const scopeIndex = args.indexOf('--scope');
const scope = scopeIndex >= 0 ? args[scopeIndex + 1] : 'user';
const plugins = (state.plugins || []).map(plugin => (
plugin.id === 'ecc@ecc' && plugin.scope === scope
? { ...plugin, enabled: true, version: '2.0.0' }
: plugin
));
writeState({ ...state, plugins });
process.exit(0);
}
if (args[0] === 'plugin' && args[1] === 'uninstall') {
const pluginId = args[2];
const scopeIndex = args.indexOf('--scope');
const scope = scopeIndex >= 0 ? args[scopeIndex + 1] : 'user';
const plugins = (state.plugins || []).filter(plugin => !(
plugin.id === pluginId && plugin.scope === scope
));
writeState({ ...state, plugins });
process.exit(0);
}
process.stderr.write(`Unsupported fake Claude invocation: ${JSON.stringify(args)}\n`);
process.exit(2);
+35
View File
@@ -0,0 +1,35 @@
'use strict';
const { main } = require('../../scripts/install-guided');
function createPlan(request) {
return {
request,
harnesses: request.harnesses.map(id => ({
id,
channel: id === 'kimi' ? 'managed-project' : 'native-plugin',
preview: {},
})),
};
}
async function applyPlan(plan) {
return {
status: 'complete',
completed: plan.harnesses.map(({ id }) => ({ id })),
retryHarnesses: [],
};
}
main([], {
applyPlan,
createPlan,
showWelcome({ output }) {
output.write('PTY_WELCOME_SHOWN\n');
},
startSpinner() {
return { stop() {} };
},
}).then(code => {
process.exitCode = code;
});
+184 -1
View File
@@ -5,11 +5,18 @@
*/
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
// Import the module
const {
VALID_PROFILES,
normalizeId,
parseBoolean,
readManagedHookConfig,
areHooksEnabled,
getHookProfile,
getDisabledHookIds,
parseProfiles,
@@ -77,6 +84,176 @@ function runTests() {
assert.strictEqual(VALID_PROFILES.size, 3);
})) passed++; else failed++;
console.log('\nHook preference sources:');
if (test('hooks default enabled when no preference source exists', () => {
withEnv({
ECC_HOOKS_ENABLED: undefined,
CLAUDE_PLUGIN_OPTION_HOOKS_ENABLED: undefined,
ECC_HOOK_CONFIG: undefined,
CLAUDE_PLUGIN_ROOT: undefined,
ECC_PLUGIN_ROOT: undefined,
}, () => {
assert.strictEqual(areHooksEnabled(), true);
});
})) passed++; else failed++;
if (test('Claude plugin options control enabled state and profile', () => {
withEnv({
ECC_HOOKS_ENABLED: undefined,
ECC_HOOK_PROFILE: undefined,
CLAUDE_PLUGIN_OPTION_HOOKS_ENABLED: 'false',
CLAUDE_PLUGIN_OPTION_HOOK_PROFILE: 'minimal',
ECC_HOOK_CONFIG: undefined,
}, () => {
assert.strictEqual(areHooksEnabled(), false);
assert.strictEqual(getHookProfile(), 'minimal');
assert.strictEqual(
isHookEnabled('pre:test', { profiles: 'minimal,standard,strict' }),
false
);
});
})) passed++; else failed++;
if (test('explicit ECC environment overrides Claude plugin options', () => {
withEnv({
ECC_HOOKS_ENABLED: 'true',
ECC_HOOK_PROFILE: 'strict',
CLAUDE_PLUGIN_OPTION_HOOKS_ENABLED: 'false',
CLAUDE_PLUGIN_OPTION_HOOK_PROFILE: 'minimal',
}, () => {
assert.strictEqual(areHooksEnabled(), true);
assert.strictEqual(getHookProfile(), 'strict');
});
assert.strictEqual(
getHookProfile({
ECC_HOOK_PROFILE: '',
CLAUDE_PLUGIN_OPTION_HOOK_PROFILE: 'minimal',
}),
'standard',
'an explicit empty ECC profile must not fall through to plugin config'
);
})) passed++; else failed++;
if (test('managed hook config is used after explicit and plugin preferences', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-hook-flags-'));
const configPath = path.join(root, 'ecc', 'setup.json');
try {
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({
hooks: { enabled: false, profile: 'minimal' },
}));
withEnv({
ECC_HOOKS_ENABLED: undefined,
ECC_HOOK_PROFILE: undefined,
CLAUDE_PLUGIN_OPTION_HOOKS_ENABLED: undefined,
CLAUDE_PLUGIN_OPTION_HOOK_PROFILE: undefined,
ECC_HOOK_CONFIG: configPath,
}, () => {
assert.deepStrictEqual(readManagedHookConfig(), {
enabled: false,
profile: 'minimal',
});
assert.strictEqual(areHooksEnabled(), false);
assert.strictEqual(getHookProfile(), 'minimal');
});
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('a hook evaluation reads managed config only once', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-hook-flags-read-once-'));
const configPath = path.join(root, 'setup.json');
const originalReadFileSync = fs.readFileSync;
let configReadCount = 0;
try {
fs.writeFileSync(configPath, JSON.stringify({
hooks: { enabled: true, profile: 'minimal' },
}));
fs.readFileSync = (...args) => {
if (args[0] === configPath) configReadCount += 1;
return originalReadFileSync(...args);
};
assert.strictEqual(isHookEnabled('pre:test', {
env: { ECC_HOOK_CONFIG: configPath },
profiles: ['minimal'],
}), true);
assert.strictEqual(configReadCount, 1);
} finally {
fs.readFileSync = originalReadFileSync;
fs.rmSync(root, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('malformed managed config emits one sanitized diagnostic', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-hook-flags-invalid-'));
const configPath = path.join(root, 'setup.json');
const originalWrite = process.stderr.write;
const diagnostics = [];
try {
fs.writeFileSync(configPath, '{"hooks":\u001b[31m');
process.stderr.write = value => {
diagnostics.push(String(value));
return true;
};
assert.deepStrictEqual(readManagedHookConfig({ ECC_HOOK_CONFIG: configPath }), {});
assert.strictEqual(diagnostics.length, 1);
assert.match(diagnostics[0], /Warning: unable to read managed ECC hook config/);
assert.strictEqual(diagnostics[0].includes('\u001b'), false);
assert.match(diagnostics[0], /setup\.json/);
} finally {
process.stderr.write = originalWrite;
fs.rmSync(root, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('boolean parsing recognizes supported values and uses its fallback', () => {
for (const value of ['1', 'true', 'yes', 'on']) {
assert.strictEqual(parseBoolean(value, false), true);
}
for (const value of ['0', 'false', 'no', 'off']) {
assert.strictEqual(parseBoolean(value, true), false);
}
assert.strictEqual(parseBoolean('invalid', false), false);
})) passed++; else failed++;
if (test('run-with-flags suppresses wrapper hooks when plugin hooks are off', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-hook-wrapper-'));
const markerPath = path.join(root, 'ran.txt');
const hookPath = path.join(root, 'marker.js');
const runner = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'run-with-flags.js');
const raw = JSON.stringify({ hook_event_name: 'PreToolUse', tool_name: 'Write' });
try {
fs.writeFileSync(
hookPath,
`'use strict';\nconst fs=require('fs');\nmodule.exports.run=function(raw){fs.writeFileSync(${JSON.stringify(markerPath)},'ran');return raw;};\n`
);
const env = {
...process.env,
CLAUDE_PLUGIN_ROOT: root,
CLAUDE_PLUGIN_OPTION_HOOKS_ENABLED: 'false',
};
delete env.ECC_HOOKS_ENABLED;
const result = spawnSync(process.execPath, [
runner,
'pre:test:marker',
'marker.js',
'minimal,standard,strict',
], {
cwd: root,
env,
input: raw,
encoding: 'utf8',
});
assert.strictEqual(result.status, 0, result.stderr);
assert.strictEqual(result.stdout, raw);
assert.ok(!fs.existsSync(markerPath), 'disabled wrapper hook must not execute');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
})) passed++; else failed++;
// normalizeId tests
console.log('\nnormalizeId:');
@@ -116,7 +293,13 @@ function runTests() {
console.log('\ngetHookProfile:');
if (test('defaults to standard when env var not set', () => {
withEnv({ ECC_HOOK_PROFILE: undefined }, () => {
withEnv({
ECC_HOOK_PROFILE: undefined,
CLAUDE_PLUGIN_OPTION_HOOK_PROFILE: undefined,
ECC_HOOK_CONFIG: undefined,
CLAUDE_PLUGIN_ROOT: undefined,
ECC_PLUGIN_ROOT: undefined,
}, () => {
assert.strictEqual(getHookProfile(), 'standard');
});
})) passed++; else failed++;
@@ -236,6 +236,26 @@ function runTests() {
passed++;
else failed++;
if (
test('Claude plugin hooks_enabled=false suppresses both dispatcher phases', () => {
for (const mode of ['sync', 'async']) {
const result = runDispatcher(mode, 'Edit', {
ECC_DRY_RUN: '1',
ECC_HOOKS_ENABLED: undefined,
CLAUDE_PLUGIN_OPTION_HOOKS_ENABLED: 'false'
});
assert.strictEqual(result.status, 0, result.stderr);
assert.deepStrictEqual(
previewedIds(result.stderr),
[],
`${mode} dispatcher must not select child hooks when plugin hooks are off`
);
}
})
)
passed++;
else failed++;
if (
test('public dispatcher IDs disable their complete phase', () => {
const entries = JSON.parse(fs.readFileSync(hooksPath, 'utf8')).hooks.PostToolUse;
+657
View File
@@ -0,0 +1,657 @@
'use strict';
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const repoRoot = path.join(__dirname, '..', '..');
const fakeClaudeScript = path.join(repoRoot, 'tests', 'fixtures', 'fake-claude-plugin.js');
const {
OFFICIAL_MARKETPLACE_URL,
buildWindowsCommandLine,
isOfficialMarketplace,
runClaude,
setupClaudePlugin,
} = require('../../scripts/lib/claude-plugin-setup');
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(`${name}`);
passed += 1;
} catch (error) {
console.log(`${name}`);
console.log(` Error: ${error.message}`);
failed += 1;
}
}
function createFixture(initialState = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc plugin setup '));
const homeDir = path.join(root, 'home with spaces');
const configDir = path.join(root, 'claude config with spaces');
const projectRoot = path.join(root, 'project with spaces');
const binDir = path.join(root, 'bin with spaces');
const statePath = path.join(root, 'claude-state.json');
const callsPath = path.join(root, 'claude-calls.jsonl');
for (const dir of [homeDir, configDir, projectRoot, binDir]) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(statePath, `${JSON.stringify({
plugins: [],
marketplaces: [],
failures: [],
...initialState,
}, null, 2)}\n`);
const launcher = path.join(binDir, process.platform === 'win32' ? 'claude.cmd' : 'claude');
const launcherSource = process.platform === 'win32'
? `@echo off\r\n"${process.execPath}" "${fakeClaudeScript}" %*\r\n`
: `#!/bin/sh\nexec "${process.execPath}" "${fakeClaudeScript}" "$@"\n`;
fs.writeFileSync(launcher, launcherSource);
if (process.platform !== 'win32') fs.chmodSync(launcher, 0o755);
return {
root,
homeDir,
configDir,
projectRoot,
binDir,
statePath,
callsPath,
settingsPath: path.join(configDir, 'settings.json'),
};
}
function cleanupFixture(fixture) {
fs.rmSync(fixture.root, { recursive: true, force: true });
}
function withFixture(initialState, fn) {
const fixture = createFixture(initialState);
const previous = {
cwd: process.cwd(),
HOME: process.env.HOME,
USERPROFILE: process.env.USERPROFILE,
PATH: process.env.PATH,
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
ECC_TEST_CLAUDE_STATE: process.env.ECC_TEST_CLAUDE_STATE,
ECC_TEST_CLAUDE_CALLS: process.env.ECC_TEST_CLAUDE_CALLS,
};
try {
process.chdir(fixture.projectRoot);
process.env.HOME = fixture.homeDir;
process.env.USERPROFILE = fixture.homeDir;
process.env.PATH = `${fixture.binDir}${path.delimiter}${previous.PATH || ''}`;
process.env.CLAUDE_CONFIG_DIR = fixture.configDir;
process.env.ECC_TEST_CLAUDE_STATE = fixture.statePath;
process.env.ECC_TEST_CLAUDE_CALLS = fixture.callsPath;
return fn(fixture);
} finally {
process.chdir(previous.cwd);
for (const [key, value] of Object.entries(previous)) {
if (key === 'cwd') continue;
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
cleanupFixture(fixture);
}
}
function setupOptions(fixture, overrides = {}) {
return {
hooks: 'standard',
homeDir: fixture.homeDir,
configDir: fixture.configDir,
projectRoot: fixture.projectRoot,
...overrides,
};
}
function readCalls(fixture) {
if (!fs.existsSync(fixture.callsPath)) return [];
return fs.readFileSync(fixture.callsPath, 'utf8')
.trim()
.split(/\r?\n/)
.filter(Boolean)
.map(line => JSON.parse(line));
}
function mutationCalls(calls) {
return calls.filter(argv => !(
argv.join(' ') === 'plugin list --json'
|| argv.join(' ') === 'plugin marketplace list --json'
));
}
function assertThrowsContaining(fn, fragments) {
assert.throws(fn, error => (
fragments.every(fragment => error.message.toLowerCase().includes(fragment.toLowerCase()))
));
}
function officialMarketplace(scope = 'user') {
return {
name: 'ecc',
source: 'github',
repo: 'affaan-m/ECC',
scope,
};
}
function installedPlugin(scope = 'user', overrides = {}) {
return {
id: 'ecc@ecc',
scope,
enabled: true,
version: '1.9.0',
...overrides,
};
}
function writeManagedState(fixture, selectedModules, operations = []) {
const statePath = path.join(fixture.configDir, 'ecc', 'install-state.json');
fs.mkdirSync(path.dirname(statePath), { recursive: true });
fs.writeFileSync(statePath, `${JSON.stringify({
schemaVersion: 'ecc.install.v1',
target: { target: 'claude' },
resolution: { selectedModules, skippedModules: [] },
operations,
}, null, 2)}\n`);
}
console.log('\n=== Claude plugin setup library tests ===\n');
test('Windows command-line fallback preserves spaced paths and JSON arguments', () => {
assert.strictEqual(
buildWindowsCommandLine(
'C:\\Program Files\\Claude\\claude.cmd',
['plugin', 'install', 'ecc@ecc', '--config', '{"hooks_enabled":false}']
),
'"C:\\Program Files\\Claude\\claude.cmd" plugin install ecc@ecc --config "{""hooks_enabled"":false}"'
);
assert.throws(
() => buildWindowsCommandLine('claude.cmd', ['plugin', 'install', 'bad&unsafe']),
/unsafe/
);
});
test('provider runner times out a hung Claude command with structured context', () => {
const timeoutError = Object.assign(new Error('spawnSync timed out'), {
code: 'ETIMEDOUT',
killed: true,
signal: 'SIGKILL',
});
const spawn = (command, args, options) => {
assert.strictEqual(command, process.execPath);
assert.deepStrictEqual(args, ['plugin', 'marketplace', 'update', 'ecc']);
assert.strictEqual(options.timeout, 25);
assert.strictEqual(options.killSignal, 'SIGKILL');
return { error: timeoutError, signal: 'SIGKILL', status: null };
};
assert.throws(
() => runClaude(
['plugin', 'marketplace', 'update', 'ecc'],
{
command: process.execPath,
phase: 'marketplace',
timeoutMs: 25,
},
{ spawnSync: spawn }
),
error => {
assert.strictEqual(error.code, 'CLAUDE_COMMAND_FAILED');
assert.strictEqual(error.phase, 'marketplace');
assert.match(error.message, /timed out after 25 ms/i);
return true;
}
);
});
test('marketplace provenance is validated according to its source type', () => {
assert.strictEqual(isOfficialMarketplace(officialMarketplace()), true);
assert.strictEqual(isOfficialMarketplace({
name: 'ecc',
source: 'git',
url: 'https://github.com/affaan-m/ECC.git',
}), true);
for (const url of [
'affaan-m/ECC',
'http://github.com/affaan-m/ECC.git',
]) {
assert.strictEqual(isOfficialMarketplace({
name: 'ecc',
source: 'git',
url,
}), false);
}
});
test('fresh installs require an explicit scope and perform no mutation', () => {
withFixture({}, fixture => {
assertThrowsContaining(
() => setupClaudePlugin(setupOptions(fixture)),
['scope', 'user', 'project', 'local']
);
assert.deepStrictEqual(mutationCalls(readCalls(fixture)), []);
assert.ok(!fs.existsSync(fixture.settingsPath));
});
});
test('an existing single-scope install defaults to its detected scope', () => {
withFixture({
plugins: [installedPlugin('project')],
marketplaces: [officialMarketplace('project')],
}, fixture => {
const result = setupClaudePlugin(setupOptions(fixture, { hooks: 'minimal' }));
assert.strictEqual(result.action, 'updated');
assert.strictEqual(result.scope, 'project');
assert.deepStrictEqual(readCalls(fixture), [
['plugin', 'list', '--json'],
['plugin', 'marketplace', 'list', '--json'],
['plugin', 'marketplace', 'update', 'ecc'],
['plugin', 'marketplace', 'list', '--json'],
['plugin', 'update', 'ecc@ecc', '--scope', 'project'],
['plugin', 'list', '--json'],
]);
const settings = JSON.parse(fs.readFileSync(fixture.settingsPath, 'utf8'));
assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hook_profile, 'minimal');
});
});
test('requesting another scope fails without the PR 2 move-scope operation', () => {
withFixture({
plugins: [installedPlugin('user')],
marketplaces: [officialMarketplace('user')],
}, fixture => {
assertThrowsContaining(
() => setupClaudePlugin(setupOptions(fixture, { scope: 'project' })),
['already installed', 'user', 'scope migration']
);
assert.deepStrictEqual(mutationCalls(readCalls(fixture)), []);
});
});
test('fresh install follows the exact inventory, marketplace, install, and verification sequence', () => {
withFixture({}, fixture => {
const result = setupClaudePlugin(setupOptions(fixture, {
scope: 'project',
hooks: 'strict',
}));
assert.strictEqual(result.action, 'installed');
assert.strictEqual(result.scope, 'project');
assert.deepStrictEqual(readCalls(fixture), [
['plugin', 'list', '--json'],
['plugin', 'marketplace', 'list', '--json'],
['plugin', 'marketplace', 'add', OFFICIAL_MARKETPLACE_URL, '--scope', 'project'],
['plugin', 'marketplace', 'list', '--json'],
[
'plugin', 'install', 'ecc@ecc',
'--scope', 'project',
'--config', 'hooks_enabled=true',
'--config', 'hook_profile=strict',
],
['plugin', 'list', '--json'],
]);
});
});
test('fresh installs support all Claude scopes while hook preferences stay user-only', () => {
for (const scope of ['user', 'project', 'local']) {
withFixture({}, fixture => {
setupClaudePlugin(setupOptions(fixture, { scope, hooks: 'minimal' }));
const calls = readCalls(fixture);
assert.ok(calls.some(argv => (
argv[0] === 'plugin'
&& argv[1] === 'marketplace'
&& argv[2] === 'add'
&& argv.includes('--scope')
&& argv[argv.indexOf('--scope') + 1] === scope
)));
assert.ok(calls.some(argv => (
argv[0] === 'plugin'
&& argv[1] === 'install'
&& argv[argv.indexOf('--scope') + 1] === scope
)));
assert.ok(fs.existsSync(fixture.settingsPath));
assert.ok(!fs.existsSync(path.join(fixture.projectRoot, '.claude', 'settings.json')));
assert.ok(!fs.existsSync(path.join(fixture.projectRoot, '.claude', 'settings.local.json')));
});
}
});
test('same-scope repeat setup updates ECC and changes durable user hook preferences', () => {
withFixture({
plugins: [installedPlugin('local')],
marketplaces: [officialMarketplace('local')],
}, fixture => {
fs.writeFileSync(fixture.settingsPath, `${JSON.stringify({
theme: 'dark',
pluginConfigs: {
'another@market': { enabled: false },
'ecc@ecc': {
enabled: true,
futureKey: { keep: true },
options: { hooks_enabled: true, hook_profile: 'minimal', unknown: 'keep' },
},
},
}, null, 2)}\n`);
setupClaudePlugin(setupOptions(fixture, { scope: 'local', hooks: 'off' }));
const settings = JSON.parse(fs.readFileSync(fixture.settingsPath, 'utf8'));
assert.strictEqual(settings.theme, 'dark');
assert.deepStrictEqual(settings.pluginConfigs['another@market'], { enabled: false });
assert.deepStrictEqual(settings.pluginConfigs['ecc@ecc'].futureKey, { keep: true });
assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.unknown, 'keep');
assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hooks_enabled, false);
assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hook_profile, 'standard');
assert.ok(!fs.readdirSync(fixture.configDir).some(name => name.includes('.tmp')));
});
});
test('repeat setup preserves the current hook preference when --hooks is omitted', () => {
withFixture({
plugins: [installedPlugin('user')],
marketplaces: [officialMarketplace('user')],
}, fixture => {
fs.writeFileSync(fixture.settingsPath, `${JSON.stringify({
pluginConfigs: {
'ecc@ecc': {
options: {
hooks_enabled: false,
hook_profile: 'strict',
},
},
},
}, null, 2)}\n`);
const result = setupClaudePlugin(setupOptions(fixture, { hooks: undefined }));
const settings = JSON.parse(fs.readFileSync(fixture.settingsPath, 'utf8'));
assert.strictEqual(result.hooks, 'off');
assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hooks_enabled, false);
assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hook_profile, 'strict');
});
});
test('malformed user settings fail preflight without provider mutation or corruption', () => {
withFixture({}, fixture => {
const malformed = '{"theme":';
fs.writeFileSync(fixture.settingsPath, malformed);
assertThrowsContaining(
() => setupClaudePlugin(setupOptions(fixture, { scope: 'user' })),
['settings', 'invalid']
);
assert.deepStrictEqual(mutationCalls(readCalls(fixture)), []);
assert.strictEqual(fs.readFileSync(fixture.settingsPath, 'utf8'), malformed);
});
});
test('legacy plugin inventory fails closed before marketplace or plugin mutation', () => {
withFixture({
plugins: [{
id: 'everything-claude-code@everything-claude-code',
scope: 'user',
enabled: true,
}],
}, fixture => {
assertThrowsContaining(
() => setupClaudePlugin(setupOptions(fixture, { scope: 'user' })),
['legacy', 'uninstall']
);
assert.deepStrictEqual(mutationCalls(readCalls(fixture)), []);
});
});
test('skills-directory ECC plugins fail closed before marketplace or plugin mutation', () => {
withFixture({
plugins: [{
id: 'ecc@skills-dir',
scope: 'user',
enabled: true,
}],
}, fixture => {
assertThrowsContaining(
() => setupClaudePlugin(setupOptions(fixture, { scope: 'user' })),
['ecc@skills-dir', 'duplicate', 'uninstall']
);
assert.deepStrictEqual(mutationCalls(readCalls(fixture)), []);
});
});
test('manual plugin layouts fail closed before provider mutation', () => {
withFixture({}, fixture => {
const manualManifest = path.join(
fixture.configDir,
'plugins',
'ecc',
'.claude-plugin',
'plugin.json'
);
fs.mkdirSync(path.dirname(manualManifest), { recursive: true });
fs.writeFileSync(manualManifest, JSON.stringify({ name: 'ecc' }));
assertThrowsContaining(
() => setupClaudePlugin(setupOptions(fixture, { scope: 'user' })),
['manual', 'ecc']
);
assert.deepStrictEqual(mutationCalls(readCalls(fixture)), []);
});
});
test('duplicate ECC plugin scopes fail closed before mutation', () => {
withFixture({
plugins: [installedPlugin('user'), installedPlugin('project')],
}, fixture => {
assertThrowsContaining(
() => setupClaudePlugin(setupOptions(fixture, { scope: 'user' })),
['multiple', 'scope']
);
assert.deepStrictEqual(mutationCalls(readCalls(fixture)), []);
});
});
test('malformed plugin JSON and malformed plugin entries fail closed', () => {
for (const pluginListResponses of [['{not-json'], [[{ id: 'ecc@ecc', enabled: true }]]]) {
withFixture({ pluginListResponses }, fixture => {
assertThrowsContaining(
() => setupClaudePlugin(setupOptions(fixture, { scope: 'user' })),
['plugin', 'inventory']
);
assert.deepStrictEqual(mutationCalls(readCalls(fixture)), []);
});
}
});
test('malformed marketplace JSON and marketplace name collisions fail closed', () => {
const cases = [
{
initial: { marketplaceListResponses: ['{not-json'] },
fragments: ['marketplace', 'inventory'],
},
{
initial: {
marketplaces: [{
name: 'ecc',
source: 'git',
url: 'https://github.com/example/not-ecc.git',
scope: 'user',
}],
},
fragments: ['marketplace', 'collision'],
},
{
initial: { marketplaces: [{ name: 'ecc' }] },
fragments: ['marketplace', 'invalid'],
},
];
for (const { initial, fragments } of cases) {
withFixture(initial, fixture => {
assertThrowsContaining(
() => setupClaudePlugin(setupOptions(fixture, { scope: 'user' })),
fragments
);
assert.deepStrictEqual(mutationCalls(readCalls(fixture)), []);
});
}
});
test('managed rules-only state is allowed but overlapping managed content is rejected', () => {
withFixture({}, fixture => {
writeManagedState(fixture, ['rules-core']);
assert.strictEqual(
setupClaudePlugin(setupOptions(fixture, { scope: 'user' })).action,
'installed'
);
});
withFixture({}, fixture => {
writeManagedState(fixture, ['rules-core', 'hooks-core']);
assertThrowsContaining(
() => setupClaudePlugin(setupOptions(fixture, { scope: 'user' })),
['managed', 'overlap']
);
assert.deepStrictEqual(mutationCalls(readCalls(fixture)), []);
});
});
test('managed overlap detection resolves symlink aliases before classifying paths', () => {
if (process.platform === 'win32') return;
withFixture({}, fixture => {
const aliasPath = path.join(fixture.configDir, 'alias');
fs.symlinkSync(fixture.configDir, aliasPath, 'dir');
writeManagedState(fixture, ['rules-core'], [{
destinationPath: path.join(aliasPath, 'hooks', 'hooks.json'),
}]);
assertThrowsContaining(
() => setupClaudePlugin(setupOptions(fixture, { scope: 'user' })),
['managed', 'overlap']
);
assert.deepStrictEqual(mutationCalls(readCalls(fixture)), []);
});
});
test('dry-run reads inventory only and never writes settings', () => {
withFixture({}, fixture => {
const result = setupClaudePlugin(setupOptions(fixture, {
scope: 'local',
hooks: 'strict',
dryRun: true,
}));
assert.strictEqual(result.action, 'would-install');
assert.strictEqual(result.dryRun, true);
assert.deepStrictEqual(mutationCalls(readCalls(fixture)), []);
assert.ok(!fs.existsSync(fixture.settingsPath));
});
});
test('provider failures stop later operations and leave settings untouched', () => {
const marketplaceArgv = [
'plugin', 'marketplace', 'add',
OFFICIAL_MARKETPLACE_URL,
'--scope', 'user',
];
const installArgv = [
'plugin', 'install', 'ecc@ecc',
'--scope', 'user',
'--config', 'hooks_enabled=true',
'--config', 'hook_profile=standard',
];
withFixture({
failures: [{
argv: installArgv,
status: 7,
stderr: 'install exploded',
times: 1,
}],
}, fixture => {
fs.writeFileSync(fixture.settingsPath, '{"theme":"dark"}\n');
assertThrowsContaining(
() => setupClaudePlugin(setupOptions(fixture, { scope: 'user' })),
['install exploded']
);
const calls = readCalls(fixture);
assert.deepStrictEqual(calls.at(-1), installArgv);
assert.strictEqual(fs.readFileSync(fixture.settingsPath, 'utf8'), '{"theme":"dark"}\n');
});
withFixture({
failures: [{
argv: marketplaceArgv,
status: 8,
stderr: 'marketplace exploded',
times: 1,
}],
}, fixture => {
assertThrowsContaining(
() => setupClaudePlugin(setupOptions(fixture, { scope: 'user' })),
['marketplace exploded']
);
const calls = readCalls(fixture);
assert.deepStrictEqual(calls.at(-1), marketplaceArgv);
assert.ok(!calls.some(argv => argv[1] === 'install'));
assert.ok(!fs.existsSync(fixture.settingsPath));
});
});
test('post-install verification rejects absent, wrong-scope, disabled, and duplicate results', () => {
const invalidVerificationResults = [
[],
[installedPlugin('project')],
[installedPlugin('user', { enabled: false })],
[installedPlugin('user'), installedPlugin('project')],
];
for (const verification of invalidVerificationResults) {
withFixture({
pluginListResponses: [[], verification],
}, fixture => {
assertThrowsContaining(
() => setupClaudePlugin(setupOptions(fixture, { scope: 'user' })),
['verify', 'ecc@ecc']
);
assert.ok(!fs.existsSync(fixture.settingsPath));
});
}
});
test('marketplace verification failure prevents plugin installation', () => {
withFixture({
marketplaceListResponses: [[], []],
}, fixture => {
assertThrowsContaining(
() => setupClaudePlugin(setupOptions(fixture, { scope: 'user' })),
['verify', 'marketplace']
);
assert.ok(!readCalls(fixture).some(argv => argv[1] === 'install'));
assert.ok(!fs.existsSync(fixture.settingsPath));
});
});
test('CLAUDE_CONFIG_DIR and paths containing spaces are honored', () => {
withFixture({}, fixture => {
const result = setupClaudePlugin(setupOptions(fixture, {
scope: 'project',
hooks: 'minimal',
}));
assert.strictEqual(path.resolve(result.settingsPath), path.resolve(fixture.settingsPath));
assert.ok(result.settingsPath.includes(' '));
assert.ok(fs.existsSync(fixture.settingsPath));
});
});
test('missing Claude executable reports an actionable recovery', () => {
withFixture({}, fixture => {
process.env.PATH = fixture.binDir;
fs.rmSync(path.join(fixture.binDir, process.platform === 'win32' ? 'claude.cmd' : 'claude'));
assertThrowsContaining(
() => setupClaudePlugin(setupOptions(fixture, { scope: 'user' })),
['claude', 'install']
);
assert.ok(!fs.existsSync(fixture.settingsPath));
});
});
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
+648
View File
@@ -0,0 +1,648 @@
'use strict';
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const repoRoot = path.join(__dirname, '..', '..');
const fakeClaudeScript = path.join(repoRoot, 'tests', 'fixtures', 'fake-claude-plugin.js');
const {
OFFICIAL_MARKETPLACE_URL,
} = require('../../scripts/lib/claude-plugin-setup');
const {
migrateClaudePluginScope,
} = require('../../scripts/lib/claude-scope-migration');
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(`${name}`);
passed += 1;
} catch (error) {
console.log(`${name}`);
console.log(` Error: ${error.stack || error.message}`);
failed += 1;
}
}
function plugin(scope, overrides = {}) {
return {
id: 'ecc@ecc',
scope,
enabled: true,
version: '1.9.0',
...overrides,
};
}
function marketplace(scope = 'user') {
return {
name: 'ecc',
source: 'github',
repo: 'affaan-m/ECC',
scope,
};
}
function createFixture(initialState = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc scope migration '));
const homeDir = path.join(root, 'home with spaces');
const configDir = path.join(root, 'config with spaces');
const projectRoot = path.join(root, 'project with spaces');
const binDir = path.join(root, 'bin with spaces');
const statePath = path.join(root, 'claude-state.json');
const callsPath = path.join(root, 'claude-calls.jsonl');
for (const dir of [homeDir, configDir, projectRoot, binDir]) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(statePath, `${JSON.stringify({
plugins: [],
marketplaces: [],
failures: [],
...initialState,
}, null, 2)}\n`);
const launcher = path.join(binDir, process.platform === 'win32' ? 'claude.cmd' : 'claude');
const launcherSource = process.platform === 'win32'
? `@echo off\r\n"${process.execPath}" "${fakeClaudeScript}" %*\r\n`
: `#!/bin/sh\nexec "${process.execPath}" "${fakeClaudeScript}" "$@"\n`;
fs.writeFileSync(launcher, launcherSource);
if (process.platform !== 'win32') fs.chmodSync(launcher, 0o755);
return {
root,
homeDir,
configDir,
projectRoot,
binDir,
statePath,
callsPath,
settingsPath: path.join(configDir, 'settings.json'),
};
}
function withFixture(initialState, fn) {
const fixture = createFixture(initialState);
const previous = {
cwd: process.cwd(),
HOME: process.env.HOME,
USERPROFILE: process.env.USERPROFILE,
PATH: process.env.PATH,
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
ECC_TEST_CLAUDE_STATE: process.env.ECC_TEST_CLAUDE_STATE,
ECC_TEST_CLAUDE_CALLS: process.env.ECC_TEST_CLAUDE_CALLS,
};
try {
process.chdir(fixture.projectRoot);
process.env.HOME = fixture.homeDir;
process.env.USERPROFILE = fixture.homeDir;
process.env.PATH = `${fixture.binDir}${path.delimiter}${previous.PATH || ''}`;
process.env.CLAUDE_CONFIG_DIR = fixture.configDir;
process.env.ECC_TEST_CLAUDE_STATE = fixture.statePath;
process.env.ECC_TEST_CLAUDE_CALLS = fixture.callsPath;
return fn(fixture);
} finally {
process.chdir(previous.cwd);
for (const [key, value] of Object.entries(previous)) {
if (key === 'cwd') continue;
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
fs.rmSync(fixture.root, { recursive: true, force: true });
}
}
function migrationOptions(fixture, scope, overrides = {}) {
return {
homeDir: fixture.homeDir,
configDir: fixture.configDir,
projectRoot: fixture.projectRoot,
scope,
...overrides,
};
}
function readCalls(fixture) {
if (!fs.existsSync(fixture.callsPath)) return [];
return fs.readFileSync(fixture.callsPath, 'utf8')
.trim()
.split(/\r?\n/)
.filter(Boolean)
.map(line => JSON.parse(line));
}
function readState(fixture) {
return JSON.parse(fs.readFileSync(fixture.statePath, 'utf8'));
}
function mutationCalls(fixture) {
return readCalls(fixture).filter(argv => !(
argv.join(' ') === 'plugin list --json'
|| argv.join(' ') === 'plugin marketplace list --json'
));
}
function captureError(fn) {
try {
fn();
} catch (error) {
return error;
}
assert.fail('Expected operation to throw');
}
function installArgv(scope, hooks = 'standard', profileOverride) {
const enabled = hooks !== 'off';
const profile = profileOverride || (hooks === 'off' ? 'standard' : hooks);
return [
'plugin', 'install', 'ecc@ecc',
'--scope', scope,
'--config', `hooks_enabled=${enabled}`,
'--config', `hook_profile=${profile}`,
];
}
function uninstallArgv(scope) {
return ['plugin', 'uninstall', 'ecc@ecc', '--scope', scope, '--keep-data'];
}
function expectedMigrationCalls(sourceScope, destinationScope, hooks = 'standard') {
return [
['plugin', 'list', '--json'],
['plugin', 'marketplace', 'list', '--json'],
['plugin', 'marketplace', 'update', 'ecc'],
['plugin', 'marketplace', 'list', '--json'],
installArgv(destinationScope, hooks),
['plugin', 'list', '--json'],
['plugin', 'list', '--json'],
uninstallArgv(sourceScope),
['plugin', 'list', '--json'],
];
}
console.log('\n=== Claude plugin scope migration tests ===\n');
test('all six directed scope pairs migrate destination-first with exact verification order', () => {
const scopes = ['user', 'project', 'local'];
for (const sourceScope of scopes) {
for (const destinationScope of scopes.filter(scope => scope !== sourceScope)) {
withFixture({
plugins: [plugin(sourceScope)],
marketplaces: [marketplace(sourceScope)],
}, fixture => {
const result = migrateClaudePluginScope(
migrationOptions(fixture, destinationScope)
);
assert.deepStrictEqual(result, {
action: 'migrated',
hooks: 'standard',
pluginId: 'ecc@ecc',
sourceScope,
scope: destinationScope,
});
assert.deepStrictEqual(
readCalls(fixture),
expectedMigrationCalls(sourceScope, destinationScope)
);
assert.deepStrictEqual(readState(fixture).plugins, [
plugin(destinationScope, { version: '2.0.0' }),
]);
assert.deepStrictEqual(readState(fixture).marketplaces, [
marketplace(sourceScope),
]);
});
}
}
});
test('a missing marketplace is added at the destination before plugin installation', () => {
withFixture({ plugins: [plugin('user')] }, fixture => {
migrateClaudePluginScope(migrationOptions(fixture, 'project'));
const calls = readCalls(fixture);
const marketplaceAdd = [
'plugin', 'marketplace', 'add',
OFFICIAL_MARKETPLACE_URL,
'--scope', 'project',
];
const addIndex = calls.findIndex(argv => (
JSON.stringify(argv) === JSON.stringify(marketplaceAdd)
));
const installIndex = calls.findIndex(argv => argv[1] === 'install');
assert.ok(addIndex >= 0);
assert.ok(installIndex >= 0);
assert.ok(addIndex < installIndex);
});
});
test('an interrupted source-plus-destination state resumes cleanup without reinstalling', () => {
withFixture({
plugins: [plugin('user'), plugin('project', { version: '2.0.0' })],
marketplaces: [marketplace('user')],
}, fixture => {
const result = migrateClaudePluginScope(migrationOptions(fixture, 'project'));
assert.strictEqual(result.action, 'resumed');
assert.strictEqual(result.sourceScope, 'user');
assert.strictEqual(result.scope, 'project');
assert.deepStrictEqual(readCalls(fixture), [
['plugin', 'list', '--json'],
['plugin', 'marketplace', 'list', '--json'],
['plugin', 'list', '--json'],
['plugin', 'list', '--json'],
uninstallArgv('user'),
['plugin', 'list', '--json'],
]);
assert.deepStrictEqual(readState(fixture).plugins, [
plugin('project', { version: '2.0.0' }),
]);
});
});
test('resume verifies an enabled destination before removing the source', () => {
withFixture({
plugins: [plugin('user'), plugin('project', { enabled: false })],
marketplaces: [marketplace('user')],
}, fixture => {
const error = captureError(() => (
migrateClaudePluginScope(migrationOptions(fixture, 'project'))
));
assert.strictEqual(error.phase, 'destination-verification');
assert.ok(!readCalls(fixture).some(argv => argv[1] === 'uninstall'));
assert.deepStrictEqual(
readState(fixture).plugins.map(entry => entry.scope).sort(),
['project', 'user']
);
});
});
test('destination-only state is idempotently already migrated, including same-scope input', () => {
for (const scope of ['user', 'project', 'local']) {
withFixture({ plugins: [plugin(scope)] }, fixture => {
const result = migrateClaudePluginScope(migrationOptions(fixture, scope));
assert.strictEqual(result.action, 'already-migrated');
assert.strictEqual(result.sourceScope, null);
assert.strictEqual(result.scope, scope);
assert.deepStrictEqual(readCalls(fixture), [
['plugin', 'list', '--json'],
['plugin', 'marketplace', 'list', '--json'],
]);
assert.deepStrictEqual(mutationCalls(fixture), []);
});
}
});
test('destination-only migration honors explicit hook preferences and reports dry-run writes', () => {
withFixture({ plugins: [plugin('local')] }, fixture => {
fs.writeFileSync(fixture.settingsPath, JSON.stringify({
pluginConfigs: {
'ecc@ecc': {
options: { hooks_enabled: false, hook_profile: 'minimal' },
},
},
}));
const result = migrateClaudePluginScope(migrationOptions(fixture, 'local', {
hooks: 'strict',
}));
assert.strictEqual(result.action, 'already-migrated');
assert.strictEqual(result.preferencesUpdated, true);
const settings = JSON.parse(fs.readFileSync(fixture.settingsPath, 'utf8'));
assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hooks_enabled, true);
assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hook_profile, 'strict');
});
withFixture({ plugins: [plugin('local')] }, fixture => {
const result = migrateClaudePluginScope(migrationOptions(fixture, 'local', {
dryRun: true,
hooks: 'off',
}));
assert.strictEqual(result.action, 'already-migrated');
assert.strictEqual(result.dryRun, true);
assert.strictEqual(result.preferencesUpdated, false);
assert.deepStrictEqual(result.plannedActions, [{
action: 'write-hook-preferences',
hooks_enabled: false,
hook_profile: 'standard',
}]);
assert.ok(!fs.existsSync(fixture.settingsPath));
});
});
test('destination-only state must be enabled before it is considered migrated', () => {
withFixture({ plugins: [plugin('project', { enabled: false })] }, fixture => {
const error = captureError(() => (
migrateClaudePluginScope(migrationOptions(fixture, 'project'))
));
assert.strictEqual(error.code, 'DESTINATION_VERIFICATION_FAILED');
assert.strictEqual(error.phase, 'destination-verification');
assert.deepStrictEqual(error.observedScopes, ['project']);
assert.deepStrictEqual(mutationCalls(fixture), []);
});
});
test('zero installs, ambiguous non-destination scopes, and invalid inventories fail closed', () => {
const cases = [
{ state: {}, scope: 'project', code: 'PLUGIN_NOT_INSTALLED' },
{
state: { plugins: [plugin('user'), plugin('local')] },
scope: 'project',
code: 'AMBIGUOUS_PLUGIN_SCOPES',
},
{
state: { plugins: [plugin('user'), plugin('project'), plugin('local')] },
scope: 'project',
code: 'AMBIGUOUS_PLUGIN_SCOPES',
},
{
state: { pluginListResponses: ['{not-json'] },
scope: 'project',
code: 'INVALID_PLUGIN_INVENTORY',
},
{
state: { pluginListResponses: [[{ id: 'ecc@ecc', enabled: true }]] },
scope: 'project',
code: 'INVALID_PLUGIN_INVENTORY',
},
{
state: {
plugins: [plugin('user')],
marketplaceListResponses: ['{not-json'],
},
scope: 'project',
code: 'INVALID_MARKETPLACE_INVENTORY',
},
];
for (const { state, scope, code } of cases) {
withFixture(state, fixture => {
const error = captureError(() => (
migrateClaudePluginScope(migrationOptions(fixture, scope))
));
assert.strictEqual(error.code, code);
assert.deepStrictEqual(mutationCalls(fixture), []);
});
}
});
test('marketplace collisions fail closed in migration dry-run and resume cleanup', () => {
const collision = {
name: 'ecc',
source: 'github',
repo: 'attacker/ecc',
scope: 'user',
};
const cases = [
{
state: {
plugins: [plugin('user')],
marketplaces: [collision],
},
options: { dryRun: true },
},
{
state: {
plugins: [plugin('user'), plugin('project')],
marketplaces: [collision],
},
options: {},
},
{
state: {
plugins: [plugin('project')],
marketplaces: [collision],
},
options: { hooks: 'strict' },
},
];
for (const { state, options } of cases) {
withFixture(state, fixture => {
const error = captureError(() => (
migrateClaudePluginScope(migrationOptions(fixture, 'project', options))
));
assert.strictEqual(error.code, 'MARKETPLACE_COLLISION');
assert.deepStrictEqual(mutationCalls(fixture), []);
assert.ok(!readCalls(fixture).some(argv => argv[1] === 'uninstall'));
assert.deepStrictEqual(
readState(fixture).plugins.map(entry => entry.scope).sort(),
state.plugins.map(entry => entry.scope).sort()
);
assert.ok(!fs.existsSync(fixture.settingsPath));
});
}
});
test('destination marketplace, install, and verification failures never uninstall the source', () => {
const destinationInstall = installArgv('project');
const cases = [
{
state: {
plugins: [plugin('user')],
marketplaces: [marketplace('user')],
failures: [{
argv: ['plugin', 'marketplace', 'update', 'ecc'],
status: 7,
stderr: 'marketplace failed',
times: 1,
}],
},
},
{
state: {
plugins: [plugin('user')],
marketplaces: [marketplace('user')],
failures: [{
argv: destinationInstall,
status: 8,
stderr: 'install failed',
times: 1,
}],
},
},
{
state: {
plugins: [plugin('user')],
marketplaces: [marketplace('user')],
pluginListResponses: [[plugin('user')], [plugin('user')]],
},
},
];
for (const { state } of cases) {
withFixture(state, fixture => {
captureError(() => (
migrateClaudePluginScope(migrationOptions(fixture, 'project'))
));
assert.ok(!readCalls(fixture).some(argv => argv[1] === 'uninstall'));
assert.ok(readState(fixture).plugins.some(entry => entry.scope === 'user'));
});
}
});
test('a concurrent non-destination install aborts before source cleanup', () => {
withFixture({
plugins: [plugin('user')],
marketplaces: [marketplace('user')],
pluginListResponses: [
[plugin('user')],
[plugin('user'), plugin('project')],
[plugin('user'), plugin('project'), plugin('local')],
],
}, fixture => {
const error = captureError(() => (
migrateClaudePluginScope(migrationOptions(fixture, 'project'))
));
assert.strictEqual(error.phase, 'concurrency-check');
assert.deepStrictEqual([...error.observedScopes].sort(), ['local', 'project', 'user']);
assert.ok(!readCalls(fixture).some(argv => argv[1] === 'uninstall'));
});
});
test('source uninstall failure reports both scopes and exact forward recovery', () => {
withFixture({
plugins: [plugin('user')],
marketplaces: [marketplace('user')],
failures: [{
argv: uninstallArgv('user'),
status: 9,
stderr: 'uninstall failed',
times: 1,
}],
}, fixture => {
const error = captureError(() => (
migrateClaudePluginScope(migrationOptions(fixture, 'project'))
));
assert.strictEqual(error.phase, 'source-uninstall');
assert.deepStrictEqual([...error.observedScopes].sort(), ['project', 'user']);
assert.deepStrictEqual(error.recovery, [
'claude plugin uninstall ecc@ecc --scope user --keep-data',
'ecc setup --mode claude-plugin --scope project --move-scope --yes',
]);
assert.ok(!readCalls(fixture).flat().includes('--prune'));
assert.deepStrictEqual(
readState(fixture).plugins.map(entry => entry.scope).sort(),
['project', 'user']
);
});
});
test('final verification failure is structured and leaves a resumable destination state', () => {
withFixture({
plugins: [plugin('user')],
marketplaces: [marketplace('user')],
pluginListResponses: [
[plugin('user')],
[plugin('user'), plugin('project')],
[plugin('user'), plugin('project')],
[],
],
}, fixture => {
const error = captureError(() => (
migrateClaudePluginScope(migrationOptions(fixture, 'project'))
));
assert.strictEqual(error.phase, 'final-verification');
assert.deepStrictEqual(error.observedScopes, []);
assert.deepStrictEqual(error.recovery, [
'ecc setup --mode claude-plugin --scope project --move-scope --yes',
]);
assert.deepStrictEqual(readState(fixture).plugins.map(entry => entry.scope), ['project']);
});
});
test('dry-run returns exact ordered actions and performs no mutation', () => {
withFixture({
plugins: [plugin('user')],
marketplaces: [marketplace('user')],
}, fixture => {
const before = fs.readFileSync(fixture.statePath, 'utf8');
const result = migrateClaudePluginScope(migrationOptions(fixture, 'project', {
dryRun: true,
}));
assert.strictEqual(result.action, 'would-migrate');
assert.strictEqual(result.dryRun, true);
assert.deepStrictEqual(result.plannedActions, [
['plugin', 'marketplace', 'update', 'ecc'],
installArgv('project'),
['plugin', 'list', '--json'],
['plugin', 'list', '--json'],
uninstallArgv('user'),
['plugin', 'list', '--json'],
]);
assert.deepStrictEqual(mutationCalls(fixture), []);
assert.strictEqual(fs.readFileSync(fixture.statePath, 'utf8'), before);
});
withFixture({
plugins: [plugin('user'), plugin('project')],
marketplaces: [marketplace('user')],
}, fixture => {
const result = migrateClaudePluginScope(migrationOptions(fixture, 'project', {
dryRun: true,
}));
assert.strictEqual(result.action, 'would-resume');
assert.strictEqual(result.sourceScope, 'user');
assert.deepStrictEqual(result.plannedActions, [
['plugin', 'list', '--json'],
['plugin', 'list', '--json'],
uninstallArgv('user'),
['plugin', 'list', '--json'],
]);
assert.deepStrictEqual(mutationCalls(fixture), []);
});
});
test('migration preserves hook preferences unless --hooks is explicit', () => {
withFixture({
plugins: [plugin('user')],
marketplaces: [marketplace('user')],
}, fixture => {
const original = {
theme: 'dark',
pluginConfigs: {
'another@market': { enabled: false },
'ecc@ecc': {
futureKey: { keep: true },
options: {
hooks_enabled: false,
hook_profile: 'strict',
unknown: 'keep',
},
},
},
};
fs.writeFileSync(fixture.settingsPath, `${JSON.stringify(original, null, 2)}\n`);
const result = migrateClaudePluginScope(migrationOptions(fixture, 'project'));
assert.strictEqual(result.hooks, 'off');
assert.deepStrictEqual(
JSON.parse(fs.readFileSync(fixture.settingsPath, 'utf8')),
original
);
assert.ok(readCalls(fixture).some(argv => (
JSON.stringify(argv) === JSON.stringify(installArgv('project', 'off', 'strict'))
)));
});
withFixture({
plugins: [plugin('user')],
marketplaces: [marketplace('user')],
}, fixture => {
fs.writeFileSync(fixture.settingsPath, JSON.stringify({
theme: 'dark',
pluginConfigs: {
'ecc@ecc': {
futureKey: true,
options: { hooks_enabled: false, hook_profile: 'minimal', unknown: 'keep' },
},
},
}));
migrateClaudePluginScope(migrationOptions(fixture, 'project', { hooks: 'strict' }));
const settings = JSON.parse(fs.readFileSync(fixture.settingsPath, 'utf8'));
assert.strictEqual(settings.theme, 'dark');
assert.strictEqual(settings.pluginConfigs['ecc@ecc'].futureKey, true);
assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.unknown, 'keep');
assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hooks_enabled, true);
assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hook_profile, 'strict');
});
});
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
+630
View File
@@ -0,0 +1,630 @@
'use strict';
const assert = require('assert');
const {
CodexPluginSetupError,
OFFICIAL_MARKETPLACE_REPO,
executeFile,
normalizeGitHubGitOrigin,
parseMarketplaceInventory,
parseMarketplaceUpgradeResult,
parsePluginInventory,
reconcileCodexPlugin,
resolveMarketplaceRepository,
} = require('../../scripts/lib/codex-plugin-setup');
const MARKETPLACE_LIST = ['plugin', 'marketplace', 'list', '--json'];
const PLUGIN_LIST = ['plugin', 'list', '--json'];
const MARKETPLACE_ADD = [
'plugin', 'marketplace', 'add', OFFICIAL_MARKETPLACE_REPO, '--json',
];
const MARKETPLACE_UPGRADE = [
'plugin', 'marketplace', 'upgrade', 'ecc', '--json',
];
const PLUGIN_ADD = ['plugin', 'add', 'ecc@ecc', '--json'];
function marketplaceInventory(installed = false) {
return JSON.stringify({
marketplaces: installed ? [{ name: 'ecc', root: '/cache/ecc' }] : [],
});
}
function pluginInventory(installed = false, overrides = {}) {
const ecc = {
pluginId: 'ecc@ecc',
name: 'ecc',
marketplaceName: 'ecc',
version: '2.0.0',
installed: true,
enabled: true,
...overrides,
};
return JSON.stringify({
installed: installed ? [ecc] : [],
available: [],
});
}
function marketplaceUpgradeResult(overrides = {}) {
return JSON.stringify({
selectedMarketplaces: ['ecc'],
upgradedRoots: ['/cache/ecc'],
errors: [],
...overrides,
});
}
function createExecFile(steps) {
const calls = [];
const execFile = (command, args, options, callback) => {
calls.push({ command, args: [...args], options: { ...options } });
const step = steps[calls.length - 1];
if (!step) {
callback(new Error(`Unexpected Codex invocation: ${args.join(' ')}`));
return;
}
if (step.command) assert.strictEqual(command, step.command);
assert.deepStrictEqual(args, step.args);
callback(step.error || null, step.stdout || '', step.stderr || '');
};
return { calls, execFile };
}
function dependenciesFor(fake, overrides = {}) {
return {
execFile: fake.execFile,
resolveMarketplaceRepository: async () => 'https://github.com/affaan-m/ECC.git',
...overrides,
};
}
async function expectSetupError(promise, code, messagePattern) {
await assert.rejects(promise, error => {
assert.ok(error instanceof CodexPluginSetupError);
assert.strictEqual(error.code, code);
assert.match(error.message, messagePattern);
return true;
});
}
async function test(name, fn) {
try {
await fn();
console.log(` \u2713 ${name}`);
return true;
} catch (error) {
console.log(` \u2717 ${name}`);
console.log(` Error: ${error.stack || error.message}`);
return false;
}
}
async function runTests() {
console.log('\n=== Codex native plugin setup library tests ===\n');
let passed = 0;
let failed = 0;
const cases = [
['parses current Codex marketplace and plugin JSON inventory shapes', () => {
assert.deepStrictEqual(
parseMarketplaceInventory(marketplaceInventory(true)),
[{ name: 'ecc', root: '/cache/ecc' }]
);
assert.strictEqual(
parsePluginInventory(pluginInventory(true)).installed[0].pluginId,
'ecc@ecc'
);
assert.strictEqual(
normalizeGitHubGitOrigin('git@github.com:affaan-m/ECC.git'),
'affaan-m/ecc'
);
}],
['resolves marketplace provenance with execFile and exact Git argv', async () => {
const fake = createExecFile([{
command: 'git',
args: ['-C', '/cache/ecc', 'remote', 'get-url', 'origin'],
stdout: 'https://github.com/affaan-m/ECC.git\n',
}]);
const repository = await resolveMarketplaceRepository(
{ name: 'ecc', root: '/cache/ecc' },
{ cwd: '/workspace with spaces' },
{ execFile: fake.execFile }
);
assert.strictEqual(repository, 'https://github.com/affaan-m/ECC.git');
assert.strictEqual(fake.calls[0].options.shell, false);
assert.strictEqual(fake.calls[0].options.cwd, '/workspace with spaces');
assert.ok(fake.calls[0].options.timeout > 0);
assert.strictEqual(fake.calls[0].options.killSignal, 'SIGKILL');
}],
['preserves the original execFile error and attaches callback output', async () => {
const original = new Error('provider failed');
const execFile = (command, args, options, callback) => {
callback(original, 'partial stdout', 'partial stderr');
};
await assert.rejects(
executeFile(execFile, 'codex', ['plugin', 'list'], {}),
error => {
assert.strictEqual(error, original);
assert.strictEqual(error.stdout, 'partial stdout');
assert.strictEqual(error.stderr, 'partial stderr');
assert.match(error.stack, /provider failed/);
return true;
}
);
}],
['maps provider and provenance timeouts to distinct structured errors', async () => {
const providerTimeout = Object.assign(new Error('timed out'), {
code: 'ETIMEDOUT',
killed: true,
signal: 'SIGKILL',
});
const provider = createExecFile([{ args: MARKETPLACE_LIST, error: providerTimeout }]);
await expectSetupError(
reconcileCodexPlugin({}, dependenciesFor(provider)),
'CODEX_COMMAND_TIMEOUT',
/timed out/i
);
const provenanceTimeout = Object.assign(new Error('timed out'), {
code: 'ETIMEDOUT',
killed: true,
signal: 'SIGKILL',
});
const provenance = createExecFile([{
command: 'git',
args: ['-C', '/cache/ecc', 'remote', 'get-url', 'origin'],
error: provenanceTimeout,
}]);
await expectSetupError(
resolveMarketplaceRepository(
{ name: 'ecc', root: '/cache/ecc' },
{},
{ execFile: provenance.execFile }
),
'MARKETPLACE_PROVENANCE_TIMEOUT',
/timed out/i
);
assert.ok(provenance.calls[0].options.timeout > 0);
assert.strictEqual(provenance.calls[0].options.killSignal, 'SIGKILL');
}],
['rejects unverifiable Git provenance without using a shell', async () => {
const gitFailure = Object.assign(new Error('no origin'), {
stderr: 'fatal: No such remote origin',
});
const fake = createExecFile([{
command: 'git',
args: ['-C', '/cache/ecc', 'remote', 'get-url', 'origin'],
error: gitFailure,
}]);
await expectSetupError(
resolveMarketplaceRepository(
{ name: 'ecc', root: '/cache/ecc' },
{},
{ execFile: fake.execFile }
),
'MARKETPLACE_COLLISION',
/provenance could not be verified/i
);
assert.strictEqual(fake.calls[0].options.shell, false);
}],
['fresh install uses exact native Codex argv and verifies both mutations', async () => {
const fake = createExecFile([
{ args: MARKETPLACE_LIST, stdout: marketplaceInventory(false) },
{ args: PLUGIN_LIST, stdout: pluginInventory(false) },
{ args: MARKETPLACE_ADD, stdout: '{"alreadyAdded":false}' },
{ args: MARKETPLACE_LIST, stdout: marketplaceInventory(true) },
{ args: PLUGIN_ADD, stdout: '{"pluginId":"ecc@ecc"}' },
{ args: PLUGIN_LIST, stdout: pluginInventory(true) },
]);
const result = await reconcileCodexPlugin(
{ cwd: '/workspace with spaces' },
dependenciesFor(fake)
);
assert.deepStrictEqual(result, {
action: 'installed',
marketplaceAction: 'added',
pluginId: 'ecc@ecc',
restartRequired: true,
});
assert.deepStrictEqual(fake.calls.map(call => call.args), [
MARKETPLACE_LIST,
PLUGIN_LIST,
MARKETPLACE_ADD,
MARKETPLACE_LIST,
PLUGIN_ADD,
PLUGIN_LIST,
]);
for (const call of fake.calls) {
assert.strictEqual(call.command, 'codex');
assert.strictEqual(call.options.cwd, '/workspace with spaces');
assert.strictEqual(call.options.shell, false);
assert.ok(call.options.timeout > 0);
assert.strictEqual(call.options.killSignal, 'SIGKILL');
assert.ok(!call.args.includes('--config'));
assert.ok(!call.args.includes('-c'));
}
}],
['already installed and enabled is refreshed and strongly verified', async () => {
const fake = createExecFile([
{ args: MARKETPLACE_LIST, stdout: marketplaceInventory(true) },
{ args: PLUGIN_LIST, stdout: pluginInventory(true) },
{ args: MARKETPLACE_UPGRADE, stdout: marketplaceUpgradeResult() },
{ args: MARKETPLACE_LIST, stdout: marketplaceInventory(true) },
{ args: PLUGIN_LIST, stdout: pluginInventory(true) },
]);
const result = await reconcileCodexPlugin({}, dependenciesFor(fake));
assert.deepStrictEqual(result, {
action: 'updated',
marketplaceAction: 'upgraded',
pluginId: 'ecc@ecc',
restartRequired: true,
});
assert.deepStrictEqual(fake.calls.map(call => call.args), [
MARKETPLACE_LIST,
PLUGIN_LIST,
MARKETPLACE_UPGRADE,
MARKETPLACE_LIST,
PLUGIN_LIST,
]);
}],
['fails closed when native refresh does not confirm the marketplace root', async () => {
const fake = createExecFile([
{ args: MARKETPLACE_LIST, stdout: marketplaceInventory(true) },
{ args: PLUGIN_LIST, stdout: pluginInventory(true) },
{
args: MARKETPLACE_UPGRADE,
stdout: marketplaceUpgradeResult({ upgradedRoots: [] }),
},
]);
await assert.rejects(
reconcileCodexPlugin({}, dependenciesFor(fake)),
error => {
assert.strictEqual(error.code, 'MARKETPLACE_REFRESH_FAILED');
assert.strictEqual(error.phase, 'marketplace-upgrade');
assert.deepStrictEqual(error.argv, MARKETPLACE_UPGRADE);
assert.match(error.message, /did not confirm.*refreshed/i);
return true;
}
);
assert.strictEqual(fake.calls.length, 3);
}],
['accepts the provider root across Windows separator and case differences', () => {
const result = parseMarketplaceUpgradeResult(
marketplaceUpgradeResult({
upgradedRoots: ['c:/users/hira/.codex/marketplaces/ecc'],
}),
{ name: 'ecc', root: 'C:\\Users\\Hira\\.codex\\marketplaces\\ecc' }
);
assert.deepStrictEqual(result.selectedMarketplaces, ['ecc']);
}],
['rejects ambiguous native refresh results for a targeted upgrade', () => {
assert.throws(
() => parseMarketplaceUpgradeResult(
marketplaceUpgradeResult({
selectedMarketplaces: ['ecc', 'other'],
upgradedRoots: ['/cache/ecc', '/cache/other'],
}),
{ name: 'ecc', root: '/cache/ecc' }
),
error => (
error.code === 'MARKETPLACE_REFRESH_FAILED'
&& error.phase === 'marketplace-upgrade'
)
);
assert.throws(
() => parseMarketplaceUpgradeResult(
marketplaceUpgradeResult({ errors: [{ message: 'dirty checkout' }] }),
{ name: 'ecc', root: '/cache/ecc' }
),
error => error.code === 'MARKETPLACE_REFRESH_FAILED'
);
}],
['fails closed when post-refresh marketplace provenance changes', async () => {
const fake = createExecFile([
{ args: MARKETPLACE_LIST, stdout: marketplaceInventory(true) },
{ args: PLUGIN_LIST, stdout: pluginInventory(true) },
{ args: MARKETPLACE_UPGRADE, stdout: marketplaceUpgradeResult() },
{ args: MARKETPLACE_LIST, stdout: marketplaceInventory(true) },
]);
let provenanceChecks = 0;
await expectSetupError(
reconcileCodexPlugin({}, dependenciesFor(fake, {
resolveMarketplaceRepository: async () => {
provenanceChecks += 1;
return provenanceChecks === 1
? 'https://github.com/affaan-m/ECC.git'
: 'https://github.com/attacker/ecc.git';
},
})),
'MARKETPLACE_COLLISION',
/not the official/i
);
assert.strictEqual(provenanceChecks, 2);
assert.strictEqual(fake.calls.length, 4);
}],
['fails closed on malformed post-refresh plugin inventory', async () => {
const fake = createExecFile([
{ args: MARKETPLACE_LIST, stdout: marketplaceInventory(true) },
{ args: PLUGIN_LIST, stdout: pluginInventory(true) },
{ args: MARKETPLACE_UPGRADE, stdout: marketplaceUpgradeResult() },
{ args: MARKETPLACE_LIST, stdout: marketplaceInventory(true) },
{ args: PLUGIN_LIST, stdout: '{not json' },
]);
await assert.rejects(
reconcileCodexPlugin({}, dependenciesFor(fake)),
error => {
assert.strictEqual(error.code, 'INVALID_PLUGIN_INVENTORY');
assert.strictEqual(error.phase, 'plugin-verification');
return true;
}
);
assert.strictEqual(fake.calls.length, 5);
}],
['dry-run fresh install is inventory-only planning', async () => {
const fake = createExecFile([
{ args: MARKETPLACE_LIST, stdout: marketplaceInventory(false) },
{ args: PLUGIN_LIST, stdout: pluginInventory(false) },
]);
const result = await reconcileCodexPlugin(
{ dryRun: true },
dependenciesFor(fake)
);
assert.deepStrictEqual(result, {
action: 'would-install',
dryRun: true,
marketplaceAction: 'would-add',
pluginId: 'ecc@ecc',
restartRequired: true,
});
assert.deepStrictEqual(fake.calls.map(call => call.args), [
MARKETPLACE_LIST,
PLUGIN_LIST,
]);
}],
['dry-run repair is inventory-only update planning', async () => {
const fake = createExecFile([
{ args: MARKETPLACE_LIST, stdout: marketplaceInventory(false) },
{ args: PLUGIN_LIST, stdout: pluginInventory(true, { enabled: false }) },
]);
const result = await reconcileCodexPlugin(
{ dryRun: true },
dependenciesFor(fake)
);
assert.deepStrictEqual(result, {
action: 'would-update',
dryRun: true,
marketplaceAction: 'would-add',
pluginId: 'ecc@ecc',
restartRequired: true,
});
assert.strictEqual(fake.calls.length, 2);
}],
['dry-run keeps reconciled state unchanged without mutation', async () => {
const fake = createExecFile([
{ args: MARKETPLACE_LIST, stdout: marketplaceInventory(true) },
{ args: PLUGIN_LIST, stdout: pluginInventory(true) },
]);
const result = await reconcileCodexPlugin(
{ dryRun: true },
dependenciesFor(fake)
);
assert.deepStrictEqual(result, {
action: 'unchanged',
dryRun: true,
marketplaceAction: 'would-upgrade',
pluginId: 'ecc@ecc',
restartRequired: false,
});
assert.strictEqual(fake.calls.length, 2);
}],
['upgrades an existing marketplace before installing a missing plugin', async () => {
const fake = createExecFile([
{ args: MARKETPLACE_LIST, stdout: marketplaceInventory(true) },
{ args: PLUGIN_LIST, stdout: pluginInventory(false) },
{ args: MARKETPLACE_UPGRADE, stdout: marketplaceUpgradeResult() },
{ args: MARKETPLACE_LIST, stdout: marketplaceInventory(true) },
{ args: PLUGIN_LIST, stdout: pluginInventory(false) },
{ args: PLUGIN_ADD, stdout: '{"pluginId":"ecc@ecc"}' },
{ args: PLUGIN_LIST, stdout: pluginInventory(true) },
]);
const result = await reconcileCodexPlugin({}, dependenciesFor(fake));
assert.strictEqual(result.action, 'installed');
assert.strictEqual(result.marketplaceAction, 'upgraded');
assert.deepStrictEqual(fake.calls.map(call => call.args), [
MARKETPLACE_LIST,
PLUGIN_LIST,
MARKETPLACE_UPGRADE,
MARKETPLACE_LIST,
PLUGIN_LIST,
PLUGIN_ADD,
PLUGIN_LIST,
]);
}],
['fails closed when the ecc marketplace has untrusted provenance', async () => {
const fake = createExecFile([
{ args: MARKETPLACE_LIST, stdout: marketplaceInventory(true) },
{ args: PLUGIN_LIST, stdout: pluginInventory(false) },
]);
await expectSetupError(
reconcileCodexPlugin({}, dependenciesFor(fake, {
resolveMarketplaceRepository: async marketplace => {
assert.strictEqual(marketplace.root, '/cache/ecc');
return 'https://github.com/attacker/ecc.git';
},
})),
'MARKETPLACE_COLLISION',
/refusing.*ecc.*marketplace/i
);
assert.deepStrictEqual(fake.calls.map(call => call.args), [
MARKETPLACE_LIST,
PLUGIN_LIST,
]);
}],
['rejects relative and insecure Git origins before marketplace mutation', async () => {
for (const origin of [
'affaan-m/ecc',
'http://github.com/affaan-m/ECC.git',
]) {
const fake = createExecFile([
{ args: MARKETPLACE_LIST, stdout: marketplaceInventory(true) },
{ args: PLUGIN_LIST, stdout: pluginInventory(false) },
]);
await expectSetupError(
reconcileCodexPlugin({}, dependenciesFor(fake, {
resolveMarketplaceRepository: async () => origin,
})),
'MARKETPLACE_COLLISION',
/not the official/i
);
assert.deepStrictEqual(fake.calls.map(call => call.args), [
MARKETPLACE_LIST,
PLUGIN_LIST,
]);
}
}],
['reports a missing Codex CLI without attempting another command', async () => {
const missing = Object.assign(new Error('spawn codex ENOENT'), { code: 'ENOENT' });
const fake = createExecFile([{ args: MARKETPLACE_LIST, error: missing }]);
await expectSetupError(
reconcileCodexPlugin({}, dependenciesFor(fake)),
'CODEX_NOT_FOUND',
/not installed|not on path/i
);
assert.strictEqual(fake.calls.length, 1);
}],
['rejects malformed marketplace and plugin JSON inventories', async () => {
assert.throws(
() => parseMarketplaceInventory('{not json'),
error => error.code === 'INVALID_MARKETPLACE_INVENTORY'
);
assert.throws(
() => parsePluginInventory('{"installed":{}}'),
error => error.code === 'INVALID_PLUGIN_INVENTORY'
);
assert.throws(
() => parseMarketplaceInventory(JSON.stringify({
marketplaces: [
{ name: 'ecc', root: '/one' },
{ name: 'ecc', root: '/two' },
],
})),
error => error.code === 'INVALID_MARKETPLACE_INVENTORY'
);
assert.throws(
() => parseMarketplaceInventory('{"marketplaces":[{"name":"ecc","root":""}]}'),
error => error.code === 'INVALID_MARKETPLACE_INVENTORY'
);
assert.throws(
() => parsePluginInventory('{"installed":[],"available":[{}]}'),
error => error.code === 'INVALID_PLUGIN_INVENTORY'
);
assert.throws(
() => parsePluginInventory(JSON.stringify({
installed: [
{ pluginId: 'ecc@ecc', installed: true, enabled: true },
{ pluginId: 'ecc@ecc', installed: true, enabled: true },
],
available: [],
})),
error => error.code === 'INVALID_PLUGIN_INVENTORY'
);
assert.strictEqual(normalizeGitHubGitOrigin(null), null);
assert.strictEqual(normalizeGitHubGitOrigin('not a repository'), null);
assert.strictEqual(normalizeGitHubGitOrigin('affaan-m/ECC'), null);
assert.strictEqual(
normalizeGitHubGitOrigin('http://github.com/affaan-m/ECC.git'),
null
);
}],
['surfaces mutation command failures with phase and exact argv', async () => {
const commandFailure = Object.assign(new Error('upgrade failed'), {
code: 1,
stderr: 'network unavailable',
});
const fake = createExecFile([
{ args: MARKETPLACE_LIST, stdout: marketplaceInventory(true) },
{ args: PLUGIN_LIST, stdout: pluginInventory(false) },
{ args: MARKETPLACE_UPGRADE, error: commandFailure },
]);
await assert.rejects(
reconcileCodexPlugin({}, dependenciesFor(fake)),
error => {
assert.strictEqual(error.code, 'CODEX_COMMAND_FAILED');
assert.strictEqual(error.phase, 'marketplace-upgrade');
assert.deepStrictEqual(error.argv, MARKETPLACE_UPGRADE);
assert.match(error.message, /network unavailable/);
return true;
}
);
}],
['fails when post-install verification does not find enabled ECC', async () => {
const fake = createExecFile([
{ args: MARKETPLACE_LIST, stdout: marketplaceInventory(false) },
{ args: PLUGIN_LIST, stdout: pluginInventory(false) },
{ args: MARKETPLACE_ADD, stdout: '{"alreadyAdded":false}' },
{ args: MARKETPLACE_LIST, stdout: marketplaceInventory(true) },
{ args: PLUGIN_ADD, stdout: '{"pluginId":"ecc@ecc"}' },
{ args: PLUGIN_LIST, stdout: pluginInventory(false) },
]);
await expectSetupError(
reconcileCodexPlugin({}, dependenciesFor(fake)),
'PLUGIN_VERIFICATION_FAILED',
/verify.*ecc@ecc/i
);
}],
['fails when marketplace verification cannot observe ECC', async () => {
const fake = createExecFile([
{ args: MARKETPLACE_LIST, stdout: marketplaceInventory(false) },
{ args: PLUGIN_LIST, stdout: pluginInventory(false) },
{ args: MARKETPLACE_ADD, stdout: '{"alreadyAdded":false}' },
{ args: MARKETPLACE_LIST, stdout: marketplaceInventory(false) },
]);
await expectSetupError(
reconcileCodexPlugin({}, dependenciesFor(fake)),
'MARKETPLACE_VERIFICATION_FAILED',
/verify.*ecc marketplace/i
);
assert.strictEqual(fake.calls.length, 4);
}],
];
for (const [name, fn] of cases) {
if (await test(name, fn)) passed += 1;
else failed += 1;
}
console.log(`\nPassed: ${passed}`);
console.log(`Failed: ${failed}`);
if (failed > 0) process.exit(1);
}
runTests().catch(error => {
console.error(error);
process.exit(1);
});
+21 -8
View File
@@ -5,6 +5,8 @@
*/
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
@@ -261,14 +263,25 @@ function runTests() {
if (test('--dry-run works with implicit install routing', () => {
const eccJs = path.resolve(__dirname, '..', '..', 'scripts', 'ecc.js');
const result = spawnSync(process.execPath, [eccJs, '--dry-run', '--json', 'typescript'], {
encoding: 'utf8',
env: { ...process.env },
});
assert.strictEqual(result.status, 0, `Expected exit 0, got ${result.status}: ${result.stderr}`);
const payload = JSON.parse(result.stdout);
assert.strictEqual(payload.dryRun, true, 'Expected dryRun=true in JSON output');
assert.deepStrictEqual(payload.plan.legacyLanguages, ['typescript']);
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-dry-run-home-'));
try {
const result = spawnSync(process.execPath, [eccJs, '--dry-run', '--json', 'typescript'], {
encoding: 'utf8',
env: {
...process.env,
CLAUDE_CONFIG_DIR: path.join(homeDir, '.claude'),
HOME: homeDir,
USERPROFILE: homeDir,
},
maxBuffer: 10 * 1024 * 1024,
});
assert.strictEqual(result.status, 0, `Expected exit 0, got ${result.status}: ${result.stderr}`);
const payload = JSON.parse(result.stdout);
assert.strictEqual(payload.dryRun, true, 'Expected dryRun=true in JSON output');
assert.deepStrictEqual(payload.plan.legacyLanguages, ['typescript']);
} finally {
fs.rmSync(homeDir, { force: true, recursive: true });
}
})) passed++; else failed++;
console.log(`\nResults: ${passed} passed, ${failed} failed`);
+55
View File
@@ -0,0 +1,55 @@
'use strict';
const assert = require('assert');
const {
normalizeGitHubGitOrigin,
} = require('../../scripts/lib/github-origin');
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(`${name}`);
return true;
} catch (error) {
console.log(`${name}`);
console.log(` Error: ${error.message}`);
return false;
}
}
console.log('\nGitHub origin normalization');
if (test('accepts only authenticated or TLS GitHub origins', () => {
assert.strictEqual(
normalizeGitHubGitOrigin('https://github.com/affaan-m/ECC.git'),
'affaan-m/ecc'
);
assert.strictEqual(
normalizeGitHubGitOrigin('ssh://git@github.com/affaan-m/ECC/'),
'affaan-m/ecc'
);
assert.strictEqual(
normalizeGitHubGitOrigin('git@github.com:affaan-m/ECC.git'),
'affaan-m/ecc'
);
})) passed++; else failed++;
if (test('rejects shorthand and insecure or unrelated origins', () => {
assert.strictEqual(normalizeGitHubGitOrigin('affaan-m/ECC'), null);
assert.strictEqual(
normalizeGitHubGitOrigin('http://github.com/affaan-m/ECC.git'),
null
);
assert.strictEqual(
normalizeGitHubGitOrigin('https://example.com/affaan-m/ECC.git'),
null
);
assert.strictEqual(normalizeGitHubGitOrigin(null), null);
})) passed++; else failed++;
console.log(`\nPassed: ${passed}`);
console.log(`Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
+185
View File
@@ -0,0 +1,185 @@
/**
* Tests for scripts/lib/harness-capabilities.js
*/
const assert = require('assert');
const { SUPPORTED_INSTALL_TARGETS } = require('../../scripts/lib/install-manifests');
const { listInstallTargetAdapters } = require('../../scripts/lib/install-targets/registry');
const {
GUIDED_HARNESS_IDS,
HARNESS_CAPABILITIES,
getHarnessCapability,
listGuidedHarnesses,
listHarnessCapabilities,
normalizeHarnessSelection,
} = require('../../scripts/lib/harness-capabilities');
function test(name, fn) {
try {
fn();
console.log(` \u2713 ${name}`);
return true;
} catch (error) {
console.log(` \u2717 ${name}`);
console.log(` Error: ${error.message}`);
return false;
}
}
function runTests() {
console.log('\n=== Testing harness capability catalog ===\n');
let passed = 0;
let failed = 0;
if (test('represents all 14 registered targets exactly once across 13 harnesses', () => {
const catalogTargetIds = HARNESS_CAPABILITIES.flatMap(harness => harness.targetIds);
const adapterTargetIds = listInstallTargetAdapters().map(adapter => adapter.target);
assert.strictEqual(HARNESS_CAPABILITIES.length, 13);
assert.strictEqual(new Set(catalogTargetIds).size, 14);
assert.deepStrictEqual([...catalogTargetIds].sort(), [...SUPPORTED_INSTALL_TARGETS].sort());
assert.deepStrictEqual([...catalogTargetIds].sort(), [...adapterTargetIds].sort());
})) passed++; else failed++;
if (test('only Claude, Codex, and Kimi are guided-ready', () => {
assert.deepStrictEqual(GUIDED_HARNESS_IDS, ['claude', 'codex', 'kimi']);
assert.deepStrictEqual(
listGuidedHarnesses().map(harness => harness.id),
['claude', 'codex', 'kimi']
);
assert.ok(HARNESS_CAPABILITIES
.filter(harness => !harness.guidedReady)
.every(harness => harness.availability === 'advanced'));
})) passed++; else failed++;
if (test('models reviewed guided install modes, roots, and scopes', () => {
const claude = getHarnessCapability('claude');
assert.deepStrictEqual(claude.targetIds, ['claude', 'claude-project']);
assert.strictEqual(claude.channel, 'native-plugin');
assert.strictEqual(claude.installMode, 'native-plugin');
assert.match(claude.destination, /selected Claude plugin scope/i);
assert.deepStrictEqual(claude.scopes, [
{ id: 'user', targetId: 'claude', root: '~/.claude' },
{ id: 'project', targetId: 'claude-project', root: './.claude' },
{ id: 'local', targetId: 'claude-project', root: './.claude' },
]);
const codex = getHarnessCapability('codex');
assert.deepStrictEqual(codex.targetIds, ['codex']);
assert.strictEqual(codex.channel, 'native-plugin');
assert.strictEqual(codex.installMode, 'native-plugin');
assert.match(codex.destination, /~\/\.codex/);
assert.deepStrictEqual(codex.scopes, [
{ id: 'native', targetId: 'codex', root: '~/.codex' },
]);
const kimi = getHarnessCapability('kimi');
assert.deepStrictEqual(kimi.targetIds, ['kimi']);
assert.strictEqual(kimi.channel, 'managed-project');
assert.strictEqual(kimi.installMode, 'managed-project');
assert.strictEqual(kimi.destination, './.kimi-code');
assert.deepStrictEqual(kimi.scopes, [
{ id: 'project', targetId: 'kimi', root: './.kimi-code' },
]);
})) passed++; else failed++;
if (test('keeps every advanced target attached to its registered root and scope', () => {
const expected = {
cursor: ['project', './.cursor'],
antigravity: ['project', './.agent'],
gemini: ['project', './.gemini'],
opencode: ['home', '~/.opencode'],
codebuddy: ['project', './.codebuddy'],
joycode: ['project', './.joycode'],
qwen: ['home', '~/.qwen'],
zed: ['project', './.zed'],
hermes: ['home', '~/.hermes'],
openclaw: ['home', '~/.openclaw'],
};
for (const [id, [scopeId, root]] of Object.entries(expected)) {
const harness = getHarnessCapability(id);
assert.strictEqual(harness.guidedReady, false, id);
assert.strictEqual(harness.availability, 'advanced', id);
assert.deepStrictEqual(harness.scopes, [
{ id: scopeId, targetId: id, root },
], id);
}
})) passed++; else failed++;
if (test('describes hook capability without claiming Kimi provider support is absent', () => {
assert.strictEqual(getHarnessCapability('claude').hooks.mode, 'profile-selection');
assert.strictEqual(getHarnessCapability('codex').hooks.mode, 'native-trust');
const kimiHooks = getHarnessCapability('kimi').hooks;
assert.strictEqual(kimiHooks.mode, 'not-configured');
assert.strictEqual(kimiHooks.eccConfigured, false);
assert.match(kimiHooks.note, /ECC hooks are not configured/i);
assert.strictEqual(kimiHooks.summary, kimiHooks.note);
assert.doesNotMatch(kimiHooks.note, /provider.*unsupported|Kimi.*unsupported/i);
})) passed++; else failed++;
if (test('does not advertise unregistered Copilot, Kiro, or Pi harnesses', () => {
for (const id of ['copilot', 'kiro', 'pi']) {
assert.strictEqual(getHarnessCapability(id), null);
assert.throws(
() => normalizeHarnessSelection(id),
/Unknown guided harness selection/
);
}
})) passed++; else failed++;
if (test('normalizes wizard selections into canonical guided order', () => {
assert.deepStrictEqual(
normalizeHarnessSelection(' KIMI CODE, Claude Code, kimi '),
['claude', 'kimi']
);
assert.deepStrictEqual(
normalizeHarnessSelection(['3', 'claude-project', 'Codex']),
['claude', 'codex', 'kimi']
);
assert.deepStrictEqual(normalizeHarnessSelection('all'), ['claude', 'codex', 'kimi']);
assert.deepStrictEqual(normalizeHarnessSelection('*'), ['claude', 'codex', 'kimi']);
})) passed++; else failed++;
if (test('rejects empty, ambiguous, advanced, and unknown wizard selections clearly', () => {
assert.throws(() => normalizeHarnessSelection(''), /At least one guided harness/);
assert.throws(() => normalizeHarnessSelection([]), /At least one guided harness/);
assert.throws(() => normalizeHarnessSelection('none'), /At least one guided harness/);
assert.throws(() => normalizeHarnessSelection('all,codex'), /cannot be combined/i);
assert.throws(() => normalizeHarnessSelection('cursor'), /advanced.*not guided-ready/i);
assert.throws(() => normalizeHarnessSelection('grok'), /Unknown guided harness selection/);
})) passed++; else failed++;
if (test('exports deeply frozen records while list helpers return safe array copies', () => {
assert.ok(Object.isFrozen(HARNESS_CAPABILITIES));
assert.ok(Object.isFrozen(HARNESS_CAPABILITIES[0]));
assert.ok(Object.isFrozen(HARNESS_CAPABILITIES[0].targetIds));
assert.ok(Object.isFrozen(HARNESS_CAPABILITIES[0].scopes));
assert.ok(Object.isFrozen(HARNESS_CAPABILITIES[0].scopes[0]));
assert.ok(Object.isFrozen(HARNESS_CAPABILITIES[0].hooks));
assert.ok(Object.isFrozen(GUIDED_HARNESS_IDS));
const first = listHarnessCapabilities();
first.pop();
assert.strictEqual(listHarnessCapabilities().length, 13);
const guided = listGuidedHarnesses();
guided.reverse();
assert.deepStrictEqual(
listGuidedHarnesses().map(harness => harness.id),
['claude', 'codex', 'kimi']
);
})) passed++; else failed++;
console.log(`\n${passed} passed, ${failed} failed\n`);
return failed === 0;
}
if (require.main === module) {
process.exit(runTests() ? 0 : 1);
}
module.exports = { runTests };
@@ -725,7 +725,7 @@ function runTests() {
assert.throws(
() => applyInstallPlan(fixture.plan),
/symlinked Claude skill path/
/outside the install root|symlinked Claude skill path/
);
assert.deepStrictEqual(fs.readdirSync(outsideRoot), []);
assert.ok(!fs.existsSync(fixture.installStatePath));
@@ -764,7 +764,7 @@ function runTests() {
assert.throws(
() => applyInstallPlan(fixture.plan, { writeInstallState() {} }),
/symlinked Claude skill path/
/outside the install root|symlinked Claude skill path/
);
assert.strictEqual(injectedSymlink, true);
assert.deepStrictEqual(fs.readdirSync(outsideRoot), []);
+189
View File
@@ -5,6 +5,7 @@
'use strict';
const assert = require('assert');
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
@@ -17,6 +18,7 @@ const {
dedupeCopyFileOperations,
listAvailableLanguages,
} = require('../../scripts/lib/install-executor');
const { applyInstallPlan: applyInstallPlanDirect } = require('../../scripts/lib/install/apply');
const REPO_ROOT = path.resolve(__dirname, '..', '..');
@@ -423,12 +425,65 @@ function runTests() {
const state = JSON.parse(fs.readFileSync(path.join(homeDir, '.claude', 'ecc', 'install-state.json'), 'utf8'));
assert.strictEqual(state.request.profile, 'minimal');
assert.deepStrictEqual(state.resolution.selectedModules, ['fixture-core']);
for (const operation of state.operations) {
assert.strictEqual(
operation.contentSha256,
crypto.createHash('sha256')
.update(fs.readFileSync(operation.destinationPath))
.digest('hex')
);
}
} finally {
cleanup(sourceRoot);
cleanup(homeDir);
}
})) passed++; else failed++;
if (test('per-operation guard runs after mkdir and immediately before a copy write', () => {
const tempDir = createTempDir('install-executor-write-guard-');
try {
const targetRoot = path.join(tempDir, 'target');
const sourcePath = writeFile(tempDir, path.join('source', 'security.md'), 'ecc\n');
const destinationPath = path.join(targetRoot, 'rules', 'security.md');
const plan = {
adapter: { id: 'kimi-project', target: 'kimi', kind: 'project' },
installStatePath: path.join(targetRoot, 'ecc-install-state.json'),
operations: [{
kind: 'copy-file',
moduleId: 'core',
sourcePath,
sourceRelativePath: 'rules/security.md',
destinationPath,
strategy: 'preserve-relative-path',
ownership: 'managed',
scaffoldOnly: false,
}],
statePreview: { operations: [] },
target: 'kimi',
targetRoot,
};
const events = [];
assert.throws(
() => applyInstallPlanDirect(plan, {
beforeOperationWrite({ operation }) {
events.push(operation.destinationPath);
assert.strictEqual(fs.existsSync(path.dirname(destinationPath)), true);
assert.strictEqual(fs.existsSync(destinationPath), false);
writeFile(targetRoot, path.join('rules', 'security.md'), 'user\n');
throw new Error('late unowned collision');
},
writeInstallState() {},
}),
/late unowned collision/
);
assert.deepStrictEqual(events, [destinationPath]);
assert.strictEqual(fs.readFileSync(destinationPath, 'utf8'), 'user\n');
} finally {
cleanup(tempDir);
}
})) passed++; else failed++;
if (test('dedupeCopyFileOperations keeps the last writer per destination (issue #2414)', () => {
// Mirrors the OpenCode command scenario: a generic commands/<name>.md source
// (preserve-relative-path) and an override .opencode/commands/<name>.md source
@@ -482,6 +537,140 @@ function runTests() {
);
})) passed++; else failed++;
if (test('applyInstallPlan refuses generic install writes outside the target root', () => {
const tempDir = createTempDir('install-executor-safety-');
try {
const sourceRoot = path.join(tempDir, 'source');
const targetRoot = path.join(tempDir, 'project', '.kimi-code');
const outsidePath = path.join(tempDir, 'outside.txt');
const sourcePath = writeFile(sourceRoot, 'skills/demo/SKILL.md', '# Demo\n');
const plan = {
mode: 'manifest',
target: 'kimi',
adapter: { id: 'kimi-project', target: 'kimi', kind: 'project' },
sourceRoot,
targetRoot,
installRoot: targetRoot,
installStatePath: path.join(targetRoot, 'ecc-install-state.json'),
warnings: [],
statePreview: {
target: 'kimi',
adapter: { id: 'kimi-project', target: 'kimi', kind: 'project' },
root: targetRoot,
operations: [],
},
operations: [
{
kind: 'copy-file',
moduleId: 'fixture',
sourcePath,
sourceRelativePath: 'skills/demo/SKILL.md',
destinationPath: outsidePath,
strategy: 'preserve-relative-path',
ownership: 'managed',
scaffoldOnly: false,
},
],
};
assert.throws(
() => applyInstallPlanDirect(plan, { writeInstallState: () => {} }),
/outside the install root/
);
assert.strictEqual(fs.existsSync(outsidePath), false);
} finally {
cleanup(tempDir);
}
})) passed++; else failed++;
if (test('Claude install without hooks-runtime leaves an existing hooks config untouched', () => {
const tempDir = createTempDir('install-executor-no-hooks-');
try {
const targetRoot = path.join(tempDir, 'home', '.claude');
const hooksPath = writeFile(
targetRoot,
'hooks/hooks.json',
'{"hooks":{"SessionStart":[{"command":"$CLAUDE_PLUGIN_ROOT/original.js"}]}}\n'
);
const before = fs.readFileSync(hooksPath, 'utf8');
const plan = {
mode: 'manifest',
target: 'claude',
adapter: { id: 'claude-home', target: 'claude', kind: 'home' },
sourceRoot: path.join(tempDir, 'source'),
targetRoot,
installRoot: targetRoot,
installStatePath: path.join(targetRoot, 'ecc', 'install-state.json'),
warnings: [],
statePreview: {
target: 'claude',
adapter: { id: 'claude-home', target: 'claude', kind: 'home' },
root: targetRoot,
operations: [],
},
operations: [],
};
applyInstallPlanDirect(plan, { writeInstallState() {} });
assert.strictEqual(fs.readFileSync(hooksPath, 'utf8'), before);
} finally {
cleanup(tempDir);
}
})) passed++; else failed++;
if (test('Claude hooks install refuses a symlinked hooks destination', () => {
if (process.platform === 'win32') return;
const tempDir = createTempDir('install-executor-hooks-symlink-');
try {
const sourceRoot = path.join(tempDir, 'source');
const targetRoot = path.join(tempDir, 'home', '.claude');
const outsideRoot = path.join(tempDir, 'outside');
const sourcePath = writeFile(
sourceRoot,
'hooks/hooks.json',
'{"hooks":{"SessionStart":[]}}\n'
);
fs.mkdirSync(targetRoot, { recursive: true });
fs.mkdirSync(outsideRoot, { recursive: true });
fs.symlinkSync(outsideRoot, path.join(targetRoot, 'hooks'), 'dir');
const plan = {
mode: 'manifest',
target: 'claude',
adapter: { id: 'claude-home', target: 'claude', kind: 'home' },
sourceRoot,
targetRoot,
installRoot: targetRoot,
installStatePath: path.join(targetRoot, 'ecc', 'install-state.json'),
warnings: [],
statePreview: {
target: 'claude',
adapter: { id: 'claude-home', target: 'claude', kind: 'home' },
root: targetRoot,
operations: [],
},
operations: [{
kind: 'copy-file',
moduleId: 'hooks-runtime',
sourcePath,
sourceRelativePath: 'hooks/hooks.json',
destinationPath: path.join(targetRoot, 'hooks', 'hooks.json'),
strategy: 'preserve-relative-path',
ownership: 'managed',
scaffoldOnly: false,
}],
};
assert.throws(
() => applyInstallPlanDirect(plan, { writeInstallState() {} }),
/outside the install root|symlinked path/
);
assert.strictEqual(fs.existsSync(path.join(outsideRoot, 'hooks.json')), false);
} finally {
cleanup(tempDir);
}
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
+87
View File
@@ -71,6 +71,93 @@ function runTests() {
assert.strictEqual(statePath, path.join(homeDir, '.claude', 'ecc', 'install-state.json'));
})) passed++; else failed++;
if (test('plans current Kimi Code project instructions, skills, and MCP config under .kimi-code', () => {
const repoRoot = path.join(__dirname, '..', '..');
const projectRoot = '/workspace/app';
const plan = planInstallTargetScaffold({
target: 'kimi',
repoRoot,
projectRoot,
modules: [
{
id: 'agents-core',
paths: ['.agents', 'agents', 'AGENTS.md'],
},
{
id: 'platform-configs',
paths: ['.kimi', '.kimi-code', 'mcp-configs'],
},
{
id: 'workflow-quality',
paths: ['skills/tdd-workflow'],
},
],
});
assert.strictEqual(plan.adapter.id, 'kimi-project');
assert.strictEqual(plan.targetRoot, path.join(projectRoot, '.kimi-code'));
assert.strictEqual(
plan.installStatePath,
path.join(projectRoot, '.kimi-code', 'ecc-install-state.json')
);
assert.ok(
plan.operations.some(operation => (
normalizedRelativePath(operation.sourceRelativePath) === '.kimi-code'
&& operation.destinationPath === path.join(projectRoot, '.kimi-code')
&& operation.strategy === 'sync-root-children'
)),
'Should recognize a current native .kimi-code source root without nesting it'
);
assert.ok(
plan.operations.some(operation => (
normalizedRelativePath(operation.sourceRelativePath) === 'AGENTS.md'
&& operation.destinationPath === path.join(projectRoot, '.kimi-code', 'AGENTS.md')
)),
'Should install project instructions at .kimi-code/AGENTS.md'
);
assert.ok(
plan.operations.some(operation => (
normalizedRelativePath(operation.sourceRelativePath) === 'skills/tdd-workflow'
&& operation.destinationPath === path.join(projectRoot, '.kimi-code', 'skills', 'tdd-workflow')
)),
'Should install directly discoverable Kimi skills under .kimi-code/skills'
);
assert.ok(
plan.operations.some(operation => (
normalizedRelativePath(operation.sourceRelativePath) === '.agents/skills'
&& operation.destinationPath === path.join(projectRoot, '.kimi-code', 'skills')
)),
'Should remap ECC Agent Skills into Kimi\'s native skill directory'
);
assert.ok(
plan.operations.some(operation => (
operation.kind === 'merge-json'
&& normalizedRelativePath(operation.sourceRelativePath) === '.mcp.json'
&& operation.destinationPath === path.join(projectRoot, '.kimi-code', 'mcp.json')
)),
'Should safely merge the project MCP config at .kimi-code/mcp.json'
);
assert.ok(
plan.operations.every(operation => (
operation.destinationPath === plan.targetRoot
|| operation.destinationPath.startsWith(`${plan.targetRoot}${path.sep}`)
)),
'Should keep every managed operation inside .kimi-code'
);
})) passed++; else failed++;
if (test('Kimi MCP planning requires an explicit ECC source root', () => {
assert.throws(
() => planInstallTargetScaffold({
target: 'kimi',
projectRoot: '/workspace/app',
modules: [{ id: 'platform-configs', paths: ['mcp-configs'] }],
}),
/repoRoot is required to plan Kimi MCP configuration/
);
})) passed++; else failed++;
if (test('plans namespaced Claude rules and flat discoverable skills', () => {
const repoRoot = path.join(__dirname, '..', '..');
const homeDir = '/Users/example';
+645
View File
@@ -0,0 +1,645 @@
'use strict';
const assert = require('assert');
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const {
applyMultiHarnessPlan,
createMultiHarnessPlan,
normalizeGuidedInstallRequest,
preflightManagedPlan,
} = require('../../scripts/lib/multi-harness-setup');
const { createInstallState } = require('../../scripts/lib/install-state');
let passed = 0;
let failed = 0;
async function test(name, fn) {
try {
await fn();
console.log(`${name}`);
passed += 1;
} catch (error) {
console.log(`${name}`);
console.log(` Error: ${error.message}`);
failed += 1;
}
}
function tempDir(prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
function writeFile(filePath, content) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content, 'utf8');
}
function sha256(content) {
return crypto.createHash('sha256').update(content).digest('hex');
}
function stateOperation(destinationPath, overrides = {}) {
return {
kind: 'copy-file',
moduleId: 'core',
sourceRelativePath: 'rules/security.md',
destinationPath,
strategy: 'preserve-relative-path',
ownership: 'managed',
scaffoldOnly: false,
...overrides,
};
}
function stateOperationFrom(operation) {
return stateOperation(operation.destinationPath, {
kind: operation.kind,
moduleId: operation.moduleId || 'core',
sourceRelativePath: operation.sourceRelativePath || 'rules/security.md',
strategy: operation.strategy || (operation.kind === 'merge-json' ? 'merge-json' : 'preserve-relative-path'),
ownership: operation.ownership || 'managed',
scaffoldOnly: Boolean(operation.scaffoldOnly),
});
}
function managedPlan(root, operations, owned = []) {
const installStatePath = path.join(root, '.kimi-code', 'ecc-install-state.json');
const plan = {
adapter: { id: 'kimi-project', target: 'kimi', kind: 'project' },
installStatePath,
operations,
target: 'kimi',
targetRoot: root,
};
plan.statePreview = createInstallState({
adapter: plan.adapter,
installStatePath,
operations: operations.map(stateOperationFrom),
request: {},
resolution: {},
source: { manifestVersion: 1 },
targetRoot: root,
});
if (owned.length > 0) {
writeManagedState(plan, {
operations: owned.map(destinationPath => stateOperation(destinationPath, {
contentSha256: sha256(fs.readFileSync(destinationPath)),
})),
});
}
return plan;
}
function writeManagedState(plan, overrides = {}) {
const state = createInstallState({
adapter: plan.adapter,
installStatePath: plan.installStatePath,
operations: [],
request: {},
resolution: {},
source: { manifestVersion: 1 },
targetRoot: plan.targetRoot,
});
const nextState = {
...state,
...overrides,
target: { ...state.target, ...(overrides.target || {}) },
operations: overrides.operations || state.operations,
};
writeFile(plan.installStatePath, `${JSON.stringify(nextState, null, 2)}\n`);
return nextState;
}
(async () => {
console.log('\n=== Multi-harness guided setup tests ===\n');
await test('normalizes provider-specific options without inventing shared semantics', () => {
assert.deepStrictEqual(normalizeGuidedInstallRequest({
harnesses: ['kimi', 'claude', 'kimi'],
claudeHooks: 'minimal',
claudeScope: 'local',
profile: 'developer',
}), {
harnesses: ['claude', 'kimi'],
claudeHooks: 'minimal',
claudeScope: 'local',
dryRun: false,
json: false,
profile: 'developer',
yes: false,
});
assert.throws(
() => normalizeGuidedInstallRequest({ harnesses: ['kimi'], claudeScope: 'user' }),
/Claude.*selected/i
);
assert.throws(
() => normalizeGuidedInstallRequest({ harnesses: ['codex'], profile: 'core' }),
/Kimi.*selected/i
);
});
await test('classifies missing, identical, managed, and JSON merge destinations', () => {
const root = tempDir('ecc-guided-preflight-');
try {
const sourceSame = path.join(root, 'sources', 'same.md');
const sourceManaged = path.join(root, 'sources', 'managed.md');
const destinationSame = path.join(root, 'same.md');
const destinationManaged = path.join(root, 'managed.md');
const destinationJson = path.join(root, 'mcp.json');
writeFile(sourceSame, 'same\n');
writeFile(sourceManaged, 'new\n');
writeFile(destinationSame, 'same\n');
writeFile(destinationManaged, 'old\n');
writeFile(destinationJson, '{"other":true}\n');
const plan = managedPlan(root, [
{ kind: 'copy-file', sourcePath: sourceSame, destinationPath: destinationSame },
stateOperation(destinationManaged, { sourcePath: sourceManaged }),
{ kind: 'merge-json', destinationPath: destinationJson, mergePayload: { ecc: true } },
{ kind: 'copy-file', sourcePath: sourceSame, destinationPath: path.join(root, 'new.md') },
], [destinationManaged]);
const result = preflightManagedPlan(plan);
assert.deepStrictEqual(result.operations.map(item => item.classification), [
'identical',
'managed-update',
'json-merge',
'create',
]);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
await test('rejects valid install-state from a different managed target identity', () => {
const root = tempDir('ecc-guided-forged-target-');
try {
const source = path.join(root, 'source.md');
const destination = path.join(root, 'AGENTS.md');
writeFile(source, 'ecc\n');
writeFile(destination, 'user\n');
const plan = managedPlan(root, [
stateOperation(destination, { sourcePath: source }),
]);
writeManagedState(plan, {
target: { id: 'cursor-project', target: 'cursor' },
operations: [stateOperation(destination)],
});
assert.throws(
() => preflightManagedPlan(plan),
/install-state.*target identity|does not belong.*Kimi/i
);
assert.strictEqual(fs.readFileSync(destination, 'utf8'), 'user\n');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
await test('rejects install-state with mismatched canonical root or state path', () => {
const root = tempDir('ecc-guided-forged-paths-');
const otherRoot = tempDir('ecc-guided-forged-other-');
try {
const source = path.join(root, 'source.md');
const destination = path.join(root, 'AGENTS.md');
writeFile(source, 'ecc\n');
writeFile(destination, 'user\n');
const plan = managedPlan(root, [
stateOperation(destination, { sourcePath: source }),
]);
for (const target of [
{ root: otherRoot },
{ installStatePath: path.join(otherRoot, 'ecc-install-state.json') },
]) {
writeManagedState(plan, {
target,
operations: [stateOperation(destination)],
});
assert.throws(
() => preflightManagedPlan(plan),
/install-state.*(root|path).*does not match/i
);
}
} finally {
fs.rmSync(root, { recursive: true, force: true });
fs.rmSync(otherRoot, { recursive: true, force: true });
}
});
await test('rejects install-state ownership claims outside the canonical target root', () => {
const root = tempDir('ecc-guided-forged-containment-');
const outside = tempDir('ecc-guided-forged-outside-');
try {
const source = path.join(root, 'source.md');
const destination = path.join(root, 'AGENTS.md');
writeFile(source, 'ecc\n');
writeFile(destination, 'user\n');
const plan = managedPlan(root, [
stateOperation(destination, { sourcePath: source }),
]);
writeManagedState(plan, {
operations: [stateOperation(path.join(outside, 'AGENTS.md'))],
});
assert.throws(
() => preflightManagedPlan(plan),
/install-state.*outside|outside the install root/i
);
} finally {
fs.rmSync(root, { recursive: true, force: true });
fs.rmSync(outside, { recursive: true, force: true });
}
});
await test('refuses an unowned differing managed-target file', () => {
const root = tempDir('ecc-guided-collision-');
try {
const source = path.join(root, 'source.md');
const destination = path.join(root, 'AGENTS.md');
writeFile(source, 'ecc\n');
writeFile(destination, 'user\n');
assert.throws(
() => preflightManagedPlan(managedPlan(root, [
{ kind: 'copy-file', sourcePath: source, destinationPath: destination },
])),
/unowned.*AGENTS\.md/i
);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
await test('same-target state without a content digest cannot claim a user file', () => {
const root = tempDir('ecc-guided-forged-same-target-');
try {
const source = path.join(root, 'source.md');
const destination = path.join(root, 'AGENTS.md');
writeFile(source, 'ecc\n');
writeFile(destination, 'user\n');
const operation = stateOperation(destination, { sourcePath: source });
const plan = managedPlan(root, [operation]);
writeManagedState(plan, { operations: [stateOperation(destination)] });
assert.throws(
() => preflightManagedPlan(plan),
/unverified ownership|content digest|unowned existing file/i
);
assert.strictEqual(fs.readFileSync(destination, 'utf8'), 'user\n');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
await test('managed ownership requires an exact operation identity and content digest', () => {
const root = tempDir('ecc-guided-managed-digest-');
try {
const source = path.join(root, 'source.md');
const destination = path.join(root, 'AGENTS.md');
writeFile(source, 'new ecc\n');
writeFile(destination, 'old ecc\n');
const operation = stateOperation(destination, { sourcePath: source });
const plan = managedPlan(root, [operation]);
writeManagedState(plan, {
operations: [stateOperation(destination, {
contentSha256: sha256('old ecc\n'),
})],
});
assert.strictEqual(
preflightManagedPlan(plan).operations[0].classification,
'managed-update'
);
writeManagedState(plan, {
operations: [stateOperation(destination, {
contentSha256: sha256('old ecc\n'),
sourceRelativePath: 'rules/other.md',
})],
});
assert.throws(
() => preflightManagedPlan(plan),
/operation identity|unverified ownership|unowned existing file/i
);
writeManagedState(plan, {
operations: [stateOperation(destination, {
contentSha256: sha256('different bytes\n'),
})],
});
assert.throws(
() => preflightManagedPlan(plan),
/content digest|unverified ownership|unowned existing file/i
);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
await test('refuses a conflicting key in an unowned JSON merge destination', () => {
const root = tempDir('ecc-guided-json-collision-');
try {
const destination = path.join(root, 'mcp.json');
writeFile(destination, JSON.stringify({
mcpServers: { github: { command: 'user-owned-server' } },
}));
assert.throws(
() => preflightManagedPlan(managedPlan(root, [
{
kind: 'merge-json',
destinationPath: destination,
mergePayload: { mcpServers: { github: { command: 'ecc-server' } } },
},
])),
/unowned JSON.*mcpServers\.github\.command/i
);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
await test('rejects symlinked managed ancestors during batch preflight', () => {
const root = tempDir('ecc-guided-symlink-root-');
const outside = tempDir('ecc-guided-symlink-outside-');
try {
const source = path.join(root, 'source.md');
const linkedDirectory = path.join(root, 'rules');
writeFile(source, 'ecc\n');
fs.symlinkSync(outside, linkedDirectory, process.platform === 'win32' ? 'junction' : 'dir');
assert.throws(
() => preflightManagedPlan(managedPlan(root, [
{
kind: 'copy-file',
sourcePath: source,
destinationPath: path.join(linkedDirectory, 'security.md'),
},
])),
/outside the install root|symlinked path/i
);
assert.strictEqual(fs.existsSync(path.join(outside, 'security.md')), false);
} finally {
fs.rmSync(root, { recursive: true, force: true });
fs.rmSync(outside, { recursive: true, force: true });
}
});
await test('rejects an unwritable Kimi destination during preflight', () => {
const root = tempDir('ecc-guided-unwritable-');
try {
const destination = path.join(root, '.kimi-code', 'rules', 'security.md');
const accessChecks = [];
const accessError = new Error('permission denied');
accessError.code = 'EACCES';
assert.throws(
() => preflightManagedPlan(managedPlan(root, [
{ kind: 'copy-file', destinationPath: destination },
]), {
accessSync(candidatePath, mode) {
accessChecks.push({ candidatePath, mode });
throw accessError;
},
}),
error => (
/Kimi destination is not writable by the current user/i.test(error.message)
&& error.message.includes(root)
)
);
assert.deepStrictEqual(accessChecks, [{
candidatePath: root,
mode: fs.constants.W_OK | fs.constants.X_OK,
}]);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
await test('real filesystem preflight rejects an unwritable project root', () => {
if (process.platform === 'win32' || (typeof process.getuid === 'function' && process.getuid() === 0)) {
return;
}
const root = tempDir('ecc-guided-real-permissions-');
const projectRoot = path.join(root, 'project');
fs.mkdirSync(projectRoot, { mode: 0o755 });
try {
fs.chmodSync(projectRoot, 0o555);
assert.throws(
() => preflightManagedPlan(managedPlan(projectRoot, [
{
kind: 'copy-file',
destinationPath: path.join(projectRoot, '.kimi-code', 'rules', 'security.md'),
},
])),
/Kimi destination is not writable by the current user/i
);
assert.strictEqual(fs.existsSync(path.join(projectRoot, '.kimi-code')), false);
} finally {
fs.chmodSync(projectRoot, 0o755);
fs.rmSync(root, { recursive: true, force: true });
}
});
await test('preflights every selected harness before applying any mutation', async () => {
const events = [];
const request = normalizeGuidedInstallRequest({
harnesses: ['claude', 'codex', 'kimi'],
claudeHooks: 'standard',
claudeScope: 'user',
profile: 'core',
});
await assert.rejects(
() => createMultiHarnessPlan(request, {
previewClaude: async () => events.push('preview:claude'),
previewCodex: async () => events.push('preview:codex'),
createManagedPlan: async () => ({ target: 'kimi' }),
preflightManaged: async () => {
events.push('preview:kimi');
throw new Error('unowned collision');
},
}),
/collision/
);
assert.deepStrictEqual(events, ['preview:claude', 'preview:codex', 'preview:kimi']);
});
await test('refuses a copy-file destination created after preview but before apply', async () => {
const root = tempDir('ecc-guided-late-copy-collision-');
try {
const source = path.join(root, 'source.md');
const destination = path.join(root, '.kimi-code', 'rules', 'security.md');
writeFile(source, 'ecc\n');
const plan = managedPlan(root, [stateOperation(destination, { sourcePath: source })]);
const preview = preflightManagedPlan(plan);
const result = await applyMultiHarnessPlan({
harnesses: [{ id: 'kimi', preview }],
request: { harnesses: ['kimi'] },
}, {
preflightManaged(candidatePlan) {
const latestPreview = preflightManagedPlan(candidatePlan);
writeFile(destination, 'user\n');
return latestPreview;
},
});
assert.strictEqual(result.status, 'failed');
assert.match(result.failure.message, /unowned existing file/i);
assert.deepStrictEqual(result.retryHarnesses, ['kimi']);
assert.strictEqual(fs.readFileSync(destination, 'utf8'), 'user\n');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
await test('refuses a late unowned copy even when its bytes match the ECC source', async () => {
const root = tempDir('ecc-guided-late-identical-copy-');
try {
const source = path.join(root, 'source.md');
const destination = path.join(root, '.kimi-code', 'rules', 'security.md');
writeFile(source, 'ecc\n');
const plan = managedPlan(root, [stateOperation(destination, { sourcePath: source })]);
const preview = preflightManagedPlan(plan);
const result = await applyMultiHarnessPlan({
harnesses: [{ id: 'kimi', preview }],
request: { harnesses: ['kimi'] },
}, {
preflightManaged(candidatePlan) {
const latestPreview = preflightManagedPlan(candidatePlan);
writeFile(destination, 'ecc\n');
return latestPreview;
},
});
assert.strictEqual(result.status, 'failed');
assert.match(result.failure.message, /destination changed after Kimi preflight/i);
assert.deepStrictEqual(result.retryHarnesses, ['kimi']);
assert.strictEqual(fs.readFileSync(destination, 'utf8'), 'ecc\n');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
await test('refuses conflicting JSON created after preview but before apply', async () => {
const root = tempDir('ecc-guided-late-json-collision-');
try {
const destination = path.join(root, '.kimi-code', 'mcp.json');
const operation = stateOperation(destination, {
kind: 'merge-json',
mergePayload: { mcpServers: { github: { command: 'ecc-server' } } },
sourceRelativePath: '.mcp.json',
strategy: 'merge-json',
});
const plan = managedPlan(root, [operation]);
const preview = preflightManagedPlan(plan);
const result = await applyMultiHarnessPlan({
harnesses: [{ id: 'kimi', preview }],
request: { harnesses: ['kimi'] },
}, {
preflightManaged(candidatePlan) {
const latestPreview = preflightManagedPlan(candidatePlan);
writeFile(destination, JSON.stringify({
mcpServers: { github: { command: 'user-server' } },
}));
return latestPreview;
},
});
assert.strictEqual(result.status, 'failed');
assert.match(result.failure.message, /unowned JSON.*mcpServers\.github\.command/i);
assert.deepStrictEqual(result.retryHarnesses, ['kimi']);
assert.deepStrictEqual(JSON.parse(fs.readFileSync(destination, 'utf8')), {
mcpServers: { github: { command: 'user-server' } },
});
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
await test('refuses an install-state file created after preview instead of overwriting it', async () => {
const root = tempDir('ecc-guided-late-state-collision-');
try {
const source = path.join(root, 'source.md');
const destination = path.join(root, '.kimi-code', 'rules', 'security.md');
writeFile(source, 'ecc\n');
const plan = managedPlan(root, [stateOperation(destination, { sourcePath: source })]);
const preview = preflightManagedPlan(plan);
const unexpectedState = '{"user":"owned"}\n';
const result = await applyMultiHarnessPlan({
harnesses: [{ id: 'kimi', preview }],
request: { harnesses: ['kimi'] },
}, {
preflightManaged(candidatePlan) {
const latestPreview = preflightManagedPlan(candidatePlan);
writeFile(plan.installStatePath, unexpectedState);
return latestPreview;
},
});
assert.strictEqual(result.status, 'failed');
assert.match(result.failure.message, /unowned or changed install-state/i);
assert.deepStrictEqual(result.retryHarnesses, ['kimi']);
assert.strictEqual(fs.existsSync(destination), false);
assert.strictEqual(fs.readFileSync(plan.installStatePath, 'utf8'), unexpectedState);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
await test('applies in catalog order and reports partial completion with an exact retry set', async () => {
const plan = {
harnesses: [
{ id: 'claude', preview: {} },
{ id: 'codex', preview: {} },
{ id: 'kimi', preview: {} },
],
request: { harnesses: ['claude', 'codex', 'kimi'] },
};
const events = [];
const result = await applyMultiHarnessPlan(plan, {
applyClaude: async () => { events.push('claude'); return { action: 'installed' }; },
applyCodex: async () => { events.push('codex'); throw new Error('verification failed'); },
applyManaged: async () => { events.push('kimi'); return { applied: true }; },
});
assert.deepStrictEqual(events, ['claude', 'codex']);
assert.strictEqual(result.status, 'partial');
assert.deepStrictEqual(result.completed.map(item => item.id), ['claude']);
assert.strictEqual(result.failure.id, 'codex');
assert.deepStrictEqual(result.retryHarnesses, ['codex', 'kimi']);
});
await test('a late Kimi permission failure retries only Kimi', async () => {
const plan = {
harnesses: [
{ id: 'claude', preview: {} },
{ id: 'codex', preview: {} },
{ id: 'kimi', preview: {} },
],
request: { harnesses: ['claude', 'codex', 'kimi'] },
};
const events = [];
const result = await applyMultiHarnessPlan(plan, {
applyClaude: async () => { events.push('claude'); return { action: 'installed' }; },
applyCodex: async () => { events.push('codex'); return { action: 'installed' }; },
applyManaged: async () => {
events.push('kimi');
const error = new Error('permission denied');
error.code = 'EACCES';
throw error;
},
});
assert.deepStrictEqual(events, ['claude', 'codex', 'kimi']);
assert.strictEqual(result.status, 'partial');
assert.deepStrictEqual(result.completed.map(item => item.id), ['claude', 'codex']);
assert.strictEqual(result.failure.id, 'kimi');
assert.deepStrictEqual(result.retryHarnesses, ['kimi']);
});
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exitCode = failed > 0 ? 1 : 0;
})();
+3 -1
View File
@@ -87,7 +87,9 @@ try {
)
);
} finally {
fs.rmSync(linkedParent, { force: true });
// Unlink the directory symlink itself. Node 24 rejects rmSync() here
// with EISDIR even though older supported runtimes accepted it.
fs.unlinkSync(linkedParent);
fs.rmSync(realParent, { recursive: true, force: true });
}
});
@@ -0,0 +1,26 @@
'use strict';
const assert = require('assert');
const { EventEmitter } = require('events');
const { questionWithCancellation } = require('../../scripts/setup');
async function run() {
const terminal = new EventEmitter();
terminal.question = () => new Promise(() => {});
const pendingAnswer = questionWithCancellation(terminal, 'Choose: ');
terminal.emit('close');
await assert.rejects(
pendingAnswer,
error => error.code === 'ABORT_ERR' && /readline was closed/i.test(error.message)
);
console.log(' ✓ readline close rejects an otherwise unresolved question');
console.log('\nResults: Passed: 1, Failed: 0');
}
run().catch(error => {
console.log(`${error.message}`);
console.log('\nResults: Passed: 0, Failed: 1');
process.exitCode = 1;
});
+174
View File
@@ -0,0 +1,174 @@
'use strict';
const assert = require('assert');
const path = require('path');
const { spawnSync } = require('child_process');
const {
CLEAR_LINE,
FRAMES,
runAnimator,
startTerminalSpinner,
} = require('../../scripts/lib/terminal-spinner');
const spinnerModule = path.join(
__dirname,
'..',
'..',
'scripts',
'lib',
'terminal-spinner.js'
);
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(`${name}`);
passed += 1;
} catch (error) {
console.log(`${name}`);
console.log(` Error: ${error.message}`);
failed += 1;
}
}
console.log('\n=== Terminal spinner tests ===\n');
test('animator advances frames and exits when its parent disconnects', () => {
const writes = [];
let tick;
let disconnect;
let cleared;
let exitCode;
const timer = Symbol('timer');
runAnimator('Applying ECC setup...', {
clearSchedule: value => { cleared = value; },
exit: code => { exitCode = code; },
onDisconnect: handler => { disconnect = handler; },
output: { write: value => writes.push(value) },
schedule: (callback, interval) => {
assert.strictEqual(interval, 80);
tick = callback;
return timer;
},
});
tick();
tick();
assert.deepStrictEqual(writes, [
`\r${FRAMES[1]} Applying ECC setup...`,
`\r${FRAMES[2]} Applying ECC setup...`,
]);
disconnect();
assert.strictEqual(cleared, timer);
assert.strictEqual(exitCode, 0);
});
test('spinner renders immediately and clears again after animator termination', () => {
const writes = [];
const spawnCalls = [];
const handlers = {};
const animatorErrors = [];
let killCount = 0;
const child = {
kill: () => { killCount += 1; },
on: (event, handler) => {
assert.strictEqual(event, 'error');
handlers[event] = handler;
},
once: (event, handler) => { handlers[event] = handler; },
};
const spinner = startTerminalSpinner('Applying ECC setup...', {
onAnimatorError: error => animatorErrors.push(error.message),
output: { write: value => writes.push(value) },
spawnProcess: (...args) => {
spawnCalls.push(args);
return child;
},
});
assert.strictEqual(writes[0], `${FRAMES[0]} Applying ECC setup...`);
assert.strictEqual(spawnCalls.length, 1);
assert.strictEqual(spawnCalls[0][0], process.execPath);
assert.deepStrictEqual(spawnCalls[0][1].slice(1), [
'--animate',
'Applying ECC setup...',
]);
assert.strictEqual(typeof handlers.error, 'function');
handlers.error(new Error('animation unavailable'));
assert.deepStrictEqual(animatorErrors, ['animation unavailable']);
spinner.stop();
writes.push(`\r${FRAMES[2]} late frame`);
handlers.close();
spinner.stop();
assert.strictEqual(killCount, 1);
assert.strictEqual(writes.at(-1), CLEAR_LINE);
assert.deepStrictEqual(writes.slice(-3), [
CLEAR_LINE,
`\r${FRAMES[2]} late frame`,
CLEAR_LINE,
]);
});
test('spinner keeps a visible first frame when the animator cannot launch', () => {
const writes = [];
const spinner = startTerminalSpinner('Applying ECC setup...', {
output: { write: value => writes.push(value) },
spawnProcess: () => { throw new Error('spawn unavailable'); },
});
spinner.stop();
assert.deepStrictEqual(writes, [
`${FRAMES[0]} Applying ECC setup...`,
CLEAR_LINE,
]);
});
test('real animator advances independently and cannot write after cleanup', () => {
const source = `
const { startTerminalSpinner } = require(${JSON.stringify(spinnerModule)});
const spinner = startTerminalSpinner('Applying ECC setup...');
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 250);
spinner.stop();
`;
const result = spawnSync(process.execPath, ['-e', source], {
encoding: 'utf8',
timeout: 3000,
});
assert.ifError(result.error);
assert.strictEqual(result.status, 0, result.stderr);
assert.match(result.stdout, new RegExp(`${FRAMES[0]} Applying ECC setup`));
assert.match(result.stdout, new RegExp(`${FRAMES[1]} Applying ECC setup`));
const clearIndex = result.stdout.lastIndexOf(CLEAR_LINE);
assert.ok(clearIndex > 0, 'real animator should clear its line');
assert.doesNotMatch(
result.stdout.slice(clearIndex + CLEAR_LINE.length),
/Applying ECC setup/,
'real animator should not render after cleanup'
);
});
test('real animator exits when its parent process disappears', () => {
const source = `
const { startTerminalSpinner } = require(${JSON.stringify(spinnerModule)});
startTerminalSpinner('Applying ECC setup...');
process.exit(0);
`;
const result = spawnSync(process.execPath, ['-e', source], {
encoding: 'utf8',
timeout: 3000,
});
assert.ifError(result.error);
assert.strictEqual(result.status, 0, result.stderr);
assert.match(result.stdout, new RegExp(`${FRAMES[0]} Applying ECC setup`));
});
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
+175
View File
@@ -0,0 +1,175 @@
'use strict';
const assert = require('assert');
const { version: ECC_VERSION } = require('../../package.json');
const {
renderTerminalWelcome,
showTerminalWelcome,
} = require('../../scripts/lib/terminal-welcome');
const OFFICIAL_LINKS = Object.freeze({
github: 'https://github.com/affaan-m/ECC',
discord: 'https://discord.gg/36yGMHGFbR',
documentation: 'https://github.com/affaan-m/ECC#readme',
githubApp: 'https://github.com/apps/ecc-tools',
});
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(`${name}`);
passed += 1;
} catch (error) {
console.log(`${name}`);
console.log(` Error: ${error.message}`);
failed += 1;
}
}
function createOutput(isTTY = true) {
const writes = [];
return {
isTTY,
writes,
write(value) {
writes.push(value);
},
};
}
console.log('\n=== Terminal welcome tests ===\n');
test('renders the cfonts block ECC wordmark with a welcome, version, and boxed links', () => {
const welcome = renderTerminalWelcome({ color: false });
const lines = welcome.split('\n');
const boxTop = lines.findIndex(line => line.startsWith(' ╭'));
const boxBottom = lines.findIndex(line => line.startsWith(' ╰'));
assert.match(welcome, /███████╗\s+██████╗\s+██████╗/);
assert.match(welcome, /╚══════╝\s+╚═════╝\s+╚═════╝/);
assert.strictEqual(welcome.includes('◕'), false);
assert.strictEqual(welcome.includes('ᴗ'), false);
assert.match(welcome, /Welcome to ECC!/);
assert.ok(welcome.includes(`v${ECC_VERSION}`));
assert.ok(boxTop > 0);
assert.ok(boxBottom > boxTop);
assert.ok(lines.slice(boxTop + 1, boxBottom).every(line => /^ {2} .* $/.test(line)));
assert.strictEqual(lines[boxTop].length, lines[boxBottom].length);
assert.ok(welcome.includes(`GitHub: ${OFFICIAL_LINKS.github}`));
assert.ok(welcome.includes(`Discord: ${OFFICIAL_LINKS.discord}`));
assert.ok(welcome.includes(`Documentation: ${OFFICIAL_LINKS.documentation}`));
assert.ok(welcome.includes(`GitHub App: ${OFFICIAL_LINKS.githubApp}`));
assert.strictEqual(welcome.includes('\x1b['), false);
});
test('renders an explicitly verified installed version when provided', () => {
const welcome = renderTerminalWelcome({ color: false, version: '2.1.0' });
assert.ok(welcome.includes('v2.1.0'));
assert.strictEqual(welcome.includes(`v${ECC_VERSION}`), ECC_VERSION === '2.1.0');
});
test('rejects unsafe installed-version text before terminal rendering', () => {
assert.throws(
() => renderTerminalWelcome({ color: false, version: '2.1.0\u001b[31m' }),
/Invalid ECC version/
);
});
test('colors the ECC wordmark from muted orange to dark baby blue', () => {
const welcome = renderTerminalWelcome({ color: true });
const orange = '\x1b[38;2;215;151;107m';
const blue = '\x1b[38;2;100;131;160m';
const dimVersion = `\x1b[2mv${ECC_VERSION}\x1b[0m`;
assert.ok(welcome.includes(orange));
assert.ok(welcome.includes(blue));
assert.ok(welcome.includes(dimVersion));
assert.ok(welcome.includes(`\n\x1b[1G ${dimVersion}`));
assert.ok(welcome.indexOf(orange) < welcome.indexOf(blue));
});
test('uses terminal color only when NO_COLOR is absent', () => {
const coloredOutput = createOutput();
const plainOutput = createOutput();
showTerminalWelcome({
action: 'installed',
env: {},
interactive: true,
output: coloredOutput,
});
showTerminalWelcome({
action: 'installed',
env: { NO_COLOR: '' },
interactive: true,
output: plainOutput,
});
assert.strictEqual(coloredOutput.writes.join('').includes('\x1b['), true);
assert.strictEqual(plainOutput.writes.join('').includes('\x1b['), false);
});
test('shows accurate copy after each verified interactive outcome', () => {
const expectedMessages = {
installed: 'Welcome to ECC!',
updated: 'ECC is updated — thank you for using ECC!',
migrated: 'ECC is configured — thank you for using ECC!',
resumed: 'ECC is configured — thank you for using ECC!',
'already-migrated': 'ECC is configured — thank you for using ECC!',
};
for (const [action, expectedMessage] of Object.entries(expectedMessages)) {
const output = createOutput();
const shown = showTerminalWelcome({
action,
env: { NO_COLOR: '1' },
interactive: true,
output,
});
assert.strictEqual(shown, true);
assert.ok(output.writes.join('').includes(expectedMessage));
}
});
test('stays quiet for cancellation, dry-runs, JSON, failures, and non-TTY output', () => {
const cases = [
{ action: 'cancelled', interactive: true },
{ action: 'would-install', dryRun: true, interactive: true },
{ action: 'installed', interactive: true, json: true },
{ action: 'failed', interactive: true },
{ action: 'installed', interactive: false },
];
for (const options of cases) {
const output = createOutput(options.interactive !== false);
const shown = showTerminalWelcome({
env: {},
output,
...options,
});
assert.strictEqual(shown, false);
assert.deepStrictEqual(output.writes, []);
}
});
test('stays quiet when the output stream itself is not a TTY', () => {
const output = createOutput(false);
const shown = showTerminalWelcome({
action: 'installed',
env: {},
interactive: true,
output,
});
assert.strictEqual(shown, false);
assert.deepStrictEqual(output.writes, []);
});
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}\n`);
if (failed > 0) process.exit(1);
+169 -13
View File
@@ -34,7 +34,9 @@ const selectiveInstallArchitecturePath = path.join(repoRoot, 'docs', 'SELECTIVE-
const opencodePackageJsonPath = path.join(repoRoot, '.opencode', 'package.json');
const opencodePackageLockPath = path.join(repoRoot, '.opencode', 'package-lock.json');
const opencodeHooksPluginPath = path.join(repoRoot, '.opencode', 'plugins', 'ecc-hooks.ts');
const hooksReadmePath = path.join(repoRoot, 'hooks', 'README.md');
const semverPattern = '[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?';
const installPrPublishedBaseline = '2.1.0';
let passed = 0;
let failed = 0;
@@ -97,6 +99,25 @@ test('package.json has version field', () => {
assert.ok(expectedVersion, 'Expected package.json version field');
});
test('package.json declares a stable release after the install PR published baseline', () => {
const parseStableSemver = (version) => {
const match = version.match(/^(\d+)\.(\d+)\.(\d+)$/);
assert.ok(match, `Expected a stable semver version, got ${version}`);
return match.slice(1).map(Number);
};
const compareSemver = (left, right) => {
for (let index = 0; index < left.length; index++) {
if (left[index] !== right[index]) return left[index] - right[index];
}
return 0;
};
assert.ok(
compareSemver(parseStableSemver(expectedVersion), parseStableSemver(installPrPublishedBaseline)) > 0,
`Expected package version after install PR baseline ${installPrPublishedBaseline}, got ${expectedVersion}`
);
});
test('package-lock.json root version matches package.json', () => {
assert.strictEqual(packageLock.version, expectedVersion);
assert.ok(packageLock.packages && packageLock.packages[''], 'Expected package-lock root package entry');
@@ -225,6 +246,34 @@ test('claude plugin.json does NOT have explicit hooks declaration', () => {
assert.ok(!('hooks' in claudePlugin), 'hooks field must NOT be declared — Claude Code v2.1+ auto-loads hooks/hooks.json by convention');
});
test('claude plugin.json exposes only supported durable hook preferences', () => {
assert.deepStrictEqual(
Object.keys(claudePlugin.userConfig || {}).sort(),
['hook_profile', 'hooks_enabled']
);
const hooksEnabled = claudePlugin.userConfig.hooks_enabled;
assert.deepStrictEqual(
Object.keys(hooksEnabled).sort(),
['default', 'description', 'title', 'type']
);
assert.strictEqual(hooksEnabled.type, 'boolean');
assert.strictEqual(hooksEnabled.default, true);
assert.ok(typeof hooksEnabled.title === 'string' && hooksEnabled.title.trim());
assert.ok(typeof hooksEnabled.description === 'string' && hooksEnabled.description.trim());
const hookProfile = claudePlugin.userConfig.hook_profile;
assert.deepStrictEqual(
Object.keys(hookProfile).sort(),
['default', 'description', 'title', 'type'],
'Claude userConfig does not support enum'
);
assert.strictEqual(hookProfile.type, 'string');
assert.strictEqual(hookProfile.default, 'standard');
assert.ok(typeof hookProfile.title === 'string' && hookProfile.title.trim());
assert.ok(typeof hookProfile.description === 'string' && hookProfile.description.trim());
});
console.log('\n=== .claude-plugin/marketplace.json ===\n');
test('claude marketplace.json exists', () => {
@@ -295,6 +344,98 @@ test('codex plugin.json mcpServers exactly matches "./.mcp.json"', () => {
assert.ok(fs.existsSync(mcpPath), `mcpServers file missing at plugin root: ${codexPlugin.mcpServers}`);
});
test('codex plugin.json explicitly declares the supported lifecycle hook bundle', () => {
assert.strictEqual(
codexPlugin.hooks,
'./hooks/codex-hooks.json',
'Codex supports a top-level hooks path; keep the ECC hook bundle explicit instead of inventing provider-specific settings'
);
const hooksPath = path.join(repoRoot, codexPlugin.hooks.replace(/^\.\//, ''));
assert.ok(fs.existsSync(hooksPath), `Codex hooks file missing at plugin root: ${codexPlugin.hooks}`);
});
test('codex lifecycle hook bundle contains only Codex 0.146-supported schema', () => {
const hooksPath = path.join(repoRoot, 'hooks', 'codex-hooks.json');
const config = loadJsonObject(hooksPath, 'hooks/codex-hooks.json');
assert.deepStrictEqual(Object.keys(config).sort(), ['description', 'hooks'], 'Codex rejects Claude\'s top-level $schema field');
const supportedEvents = new Set([
'PreToolUse', 'PermissionRequest', 'PostToolUse', 'PreCompact', 'PostCompact',
'SessionStart', 'SessionEnd', 'SubagentStart', 'SubagentStop',
'UserPromptSubmit', 'Stop'
]);
assert.deepStrictEqual(
Object.keys(config.hooks || {}),
['SessionStart'],
'Only the verified, non-blocking SessionStart hook ships natively; Claude hook profiles are not Codex hook profiles'
);
assert.deepStrictEqual(
config.hooks.SessionStart.map(group => group.id),
['session:start'],
'Do not ship Claude handlers that surface hook failures in Codex'
);
for (const [event, groups] of Object.entries(config.hooks || {})) {
assert.ok(supportedEvents.has(event), `Unsupported Codex hook event: ${event}`);
assert.ok(Array.isArray(groups) && groups.length > 0, `Expected non-empty matcher groups for ${event}`);
for (const group of groups) {
assert.ok(Array.isArray(group.hooks) && group.hooks.length > 0, `Expected non-empty handlers for ${event}`);
for (const handler of group.hooks) {
assert.strictEqual(handler.type, 'command', `Codex 0.146 only executes command handlers (${event})`);
assert.ok(!Object.prototype.hasOwnProperty.call(handler, 'async'), `Codex 0.146 skips async handlers (${event})`);
assert.ok(
handler.command.includes('process.env.CLAUDE_PLUGIN_ROOT=process.env.PLUGIN_ROOT'),
`Codex plugin hooks must pin Claude-compatible bootstrap resolution to Codex PLUGIN_ROOT (${event})`
);
if (event === 'SessionEnd' && Number.isFinite(handler.timeout)) {
assert.ok(handler.timeout <= 3, 'Codex clamps SessionEnd timeouts to 3 seconds');
}
}
}
}
const claudeConfig = loadJsonObject(path.join(repoRoot, 'hooks', 'hooks.json'), 'hooks/hooks.json');
const sourceSessionStart = claudeConfig.hooks.SessionStart.find(group => group.id === 'session:start');
const expectedSessionStart = {
...sourceSessionStart,
hooks: sourceSessionStart.hooks.map(handler => ({
...handler,
command: handler.command.replace(
'node -e "',
'node -e "if(!process.env.PLUGIN_ROOT)throw new Error(\'Missing Codex PLUGIN_ROOT\');process.env.CLAUDE_PLUGIN_ROOT=process.env.PLUGIN_ROOT;'
)
}))
};
assert.deepStrictEqual(config.hooks.SessionStart[0], expectedSessionStart, 'Codex SessionStart hook must track its canonical implementation with a Codex-root bootstrap');
});
test('hook documentation distinguishes the Claude off setting from runtime profiles', () => {
const source = fs.readFileSync(hooksReadmePath, 'utf8');
assert.ok(source.includes('Claude setup-only value:'), 'Expected hooks README to label off as a Claude setup-only value');
const runtimeProfiles = source.match(/Runtime hook profiles:\n((?:- `[^`]+`[^\n]*\n)+)/);
assert.ok(runtimeProfiles, 'Expected hooks README to identify runtime hook profiles separately');
assert.ok(!runtimeProfiles[1].includes('`off`'), 'off is a Claude setup value, not a runtime hook profile');
for (const profile of ['minimal', 'standard', 'strict']) {
assert.ok(runtimeProfiles[1].includes(`\`${profile}\``), `Expected documented runtime hook profile: ${profile}`);
}
});
test('Chinese capability matrix documents the native Codex SessionStart hook', () => {
const source = fs.readFileSync(zhCnReadmePath, 'utf8');
assert.ok(
source.includes('| **钩子事件** | 8 种类型 | 15 种类型 | SessionStart1 种类型) | 11 种类型 |'),
'Expected the Codex capability column to document one native SessionStart event'
);
assert.ok(
source.includes('| **钩子脚本** | 20+ 个脚本 | 16 个脚本 (DRY 适配器) | 1 个 SessionStart 引导脚本 | 插件钩子 |'),
'Expected the Codex capability column to document the SessionStart bootstrap script'
);
assert.ok(
!source.includes('Codex 缺少钩子功能'),
'Codex architecture guidance must not contradict its native SessionStart hook'
);
});
test('codex plugin.json has interface.displayName', () => {
assert.ok(codexPlugin.interface && codexPlugin.interface.displayName, 'Expected interface.displayName for plugin directory presentation');
});
@@ -393,21 +534,30 @@ test('marketplace.json plugin version matches package.json', () => {
assert.strictEqual(marketplace.plugins[0].version, expectedVersion);
});
test('marketplace local plugin path resolves to a concrete plugin subdirectory (#2128)', () => {
// Codex does not discover plugins whose local marketplace source.path is the
// marketplace root itself ("./") — verified against Codex CLI 0.137.0 and
// the official docs ($REPO_ROOT/plugins/<name>). The entry must point at a
// real plugin folder strictly inside the repo.
test('marketplace local plugin source is a self-contained native Codex bundle', () => {
// Codex 0.146.0 accepts the marketplace root as a plugin source and copies
// that source into its install cache. Parent-relative references from a thin
// subdirectory are broken after that copy, so every bundled path must remain
// inside the selected source root.
for (const plugin of marketplace.plugins) {
if (!plugin.source || plugin.source.source !== 'local') {
continue;
}
assert.ok(plugin.source.path.startsWith('./'), `Codex marketplace source.path must be ./-prefixed: ${plugin.source.path}`);
const resolvedRoot = path.resolve(repoRoot, plugin.source.path);
assert.notStrictEqual(resolvedRoot, repoRoot, `Codex never discovers "./" marketplace roots — source.path must target a plugin subdirectory (#2128), got: ${plugin.source.path}`);
assert.ok(resolvedRoot.startsWith(repoRoot + path.sep), `Expected local marketplace path to stay inside the repo, got: ${plugin.source.path}`);
assert.ok(fs.existsSync(path.join(resolvedRoot, '.codex-plugin', 'plugin.json')), `Codex plugin manifest missing under resolved plugin folder: ${plugin.source.path}`);
const sourceRoot = path.resolve(repoRoot, plugin.source.path);
assert.strictEqual(sourceRoot, repoRoot, `ECC's native Codex bundle must use the self-contained repository root, got: ${plugin.source.path}`);
const manifest = loadJsonObject(path.join(sourceRoot, '.codex-plugin', 'plugin.json'), 'marketplace Codex plugin manifest');
for (const field of ['skills', 'mcpServers', 'hooks']) {
assert.strictEqual(typeof manifest[field], 'string', `Expected Codex manifest ${field} path`);
const target = path.resolve(sourceRoot, manifest[field]);
assert.ok(target === sourceRoot || target.startsWith(sourceRoot + path.sep), `${field} escapes the installed source root: ${manifest[field]}`);
assert.ok(fs.existsSync(target), `${field} target is missing from the installed source root: ${manifest[field]}`);
}
assert.ok(fs.existsSync(path.join(sourceRoot, 'scripts', 'hooks', 'plugin-hook-bootstrap.js')), 'Codex hook runtime must ship inside the installed source root');
assert.ok(fs.existsSync(path.join(sourceRoot, 'skills', 'configure-ecc', 'SKILL.md')), 'Codex configure-ecc skill must ship inside the installed source root');
}
});
@@ -458,13 +608,14 @@ test('plugins/ecc manifest interface assets resolve to root assets', () => {
}
});
test('plugins/ecc README documents the upstream Codex fragility', () => {
test('plugins/ecc README marks the thin folder as a legacy compatibility artifact', () => {
const readmePath = path.join(repoRoot, 'plugins', 'ecc', 'README.md');
assert.ok(fs.existsSync(readmePath), 'Expected plugins/ecc/README.md');
const source = fs.readFileSync(readmePath, 'utf8');
assert.ok(source.includes('openai/codex'), 'plugins/ecc README must link the upstream Codex discovery issue');
assert.ok(source.includes('legacy compatibility artifact'));
assert.ok(source.includes('repository root'));
assert.ok(source.includes('check-plugin-cache.js'), 'plugins/ecc README must point at the cache health check');
assert.ok(source.includes('sync-ecc-to-codex.sh'), 'plugins/ecc README must point at the supported manual sync flow');
assert.ok(!source.includes('points at this directory'));
});
test('.opencode/package.json version matches package.json', () => {
@@ -514,7 +665,12 @@ test('.codex-plugin README uses current marketplace add flow', () => {
const readme = fs.readFileSync(path.join(repoRoot, '.codex-plugin', 'README.md'), 'utf8');
assert.ok(readme.includes('codex plugin marketplace add'), 'Expected .codex-plugin README to document codex plugin marketplace add');
assert.ok(readme.includes('codex plugin marketplace add affaan-m/ECC'), 'Expected .codex-plugin README to document the canonical ECC repo marketplace source');
assert.ok(readme.includes('Official Plugin Directory publishing is coming soon'), 'Expected .codex-plugin README to document current official directory status');
assert.ok(readme.includes('codex plugin add ecc@ecc'), 'Expected .codex-plugin README to document the current Codex install command');
assert.ok(readme.includes('codex plugin list --json'), 'Expected .codex-plugin README to document a machine-checkable verification command');
assert.ok(readme.includes('safe to run again'), 'Expected .codex-plugin README to explain idempotent marketplace and plugin registration');
assert.ok(/does not\s+use Claude's `user`, `project`, or `local` install scopes/.test(readme), 'Expected .codex-plugin README to distinguish Codex plugin state from Claude scopes');
assert.ok(readme.includes('review and trust'), 'Expected .codex-plugin README to explain Codex hook trust');
assert.ok(readme.includes('legacy managed sync'), 'Expected .codex-plugin README to distinguish native plugins from the legacy managed sync');
assert.ok(!/\bcodex plugin install\b/.test(readme), 'codex plugin install is not a current Codex CLI command');
});
+3 -3
View File
@@ -75,7 +75,7 @@ function runTests() {
assert.ok(payload.matches[0].reasons.some(reason => reason.includes('security')));
assert.strictEqual(
payload.matches[0].installCommand,
'npx ecc install --profile minimal --target claude --with capability:security'
'npx ecc-universal install --profile minimal --target claude --with capability:security'
);
assert.ok(payload.profiles.some(profile => profile.id === 'security'));
assert.ok(payload.profiles.find(profile => profile.id === 'security').installCommand.includes('--profile security'));
@@ -87,8 +87,8 @@ function runTests() {
assert.strictEqual(result.status, 0, result.stderr);
assert.match(result.stdout, /ECC consult/);
assert.match(result.stdout, /capability:security/);
assert.match(result.stdout, /npx ecc install --profile minimal --target claude --with capability:security/);
assert.match(result.stdout, /npx ecc plan --profile minimal --target claude --with capability:security/);
assert.match(result.stdout, /npx ecc-universal install --profile minimal --target claude --with capability:security/);
assert.match(result.stdout, /npx ecc-universal plan --profile minimal --target claude --with capability:security/);
})) passed++; else failed++;
if (test('recommends machine-learning component and reviewer agent', () => {
+346
View File
@@ -0,0 +1,346 @@
/**
* Published npm binary aliases for the primary ECC CLI.
*
* The CI matrix sets CLAUDE_CODE_PACKAGE_MANAGER. Each lane must execute the
* packed artifact through its own package runner instead of silently falling
* back to npx.
*/
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const repoRoot = path.join(__dirname, '..', '..');
const packageJson = JSON.parse(
fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')
);
const packageLock = JSON.parse(
fs.readFileSync(path.join(repoRoot, 'package-lock.json'), 'utf8')
);
const activePackageManager = process.env.CLAUDE_CODE_PACKAGE_MANAGER || 'npm';
const supportedPackageManagers = new Set(['npm', 'pnpm', 'yarn', 'bun']);
const windowsPackageCommands = new Set([
'bun',
'bunx',
'npm',
'npx',
'pnpm',
'yarn',
]);
const unsafeWindowsShellChars = /[\r\n"&|<>^%!()]/;
const commandTimeoutMs = 90_000;
let passed = 0;
let failed = 0;
let packedFixture;
let localPackedProject;
function test(name, fn) {
try {
fn();
console.log(`${name}`);
passed += 1;
} catch (error) {
console.log(`${name}`);
console.log(` Error: ${error.message}`);
failed += 1;
}
}
function quoteWindowsCommandToken(value) {
const token = String(value);
assert.doesNotMatch(
token,
unsafeWindowsShellChars,
'Package command contains characters that are unsafe for cmd.exe'
);
if (token === '') return '""';
return /\s/.test(token) ? `"${token}"` : token;
}
function getSpawnInvocation(command, args, platform = process.platform) {
if (platform !== 'win32' || !windowsPackageCommands.has(command)) {
return { args, command };
}
// Node 18.20+/20.12+ refuse to spawn .cmd files directly after the
// CVE-2024-27980 mitigation. Build one validated command line so cmd.exe
// preserves path arguments containing spaces instead of re-splitting them.
return {
args: undefined,
command: [`${command}.cmd`, ...args]
.map(quoteWindowsCommandToken)
.join(' '),
shell: true,
};
}
function withPathPrefix(environment, prefix) {
const nextEnvironment = { ...environment };
const pathKey = Object.keys(nextEnvironment)
.find(key => key.toLowerCase() === 'path') || 'PATH';
nextEnvironment[pathKey] = [prefix, nextEnvironment[pathKey]]
.filter(Boolean)
.join(path.delimiter);
return nextEnvironment;
}
function run(command, args, options = {}) {
const invocation = getSpawnInvocation(command, args);
const result = spawnSync(invocation.command, invocation.args, {
cwd: options.cwd || repoRoot,
encoding: 'utf8',
env: options.env || process.env,
maxBuffer: 10 * 1024 * 1024,
shell: invocation.shell || false,
timeout: commandTimeoutMs,
windowsHide: true,
});
assert.ifError(result.error);
assert.strictEqual(
result.status,
0,
[
`${command} ${args.join(' ')} exited with ${result.status}`,
result.stdout,
result.stderr,
].filter(Boolean).join('\n')
);
return result;
}
function getPackedFixture() {
if (packedFixture) {
return packedFixture;
}
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-universal-bin-'));
const packResult = run(
'npm',
['pack', '--json', '--ignore-scripts', '--pack-destination', directory]
);
const packOutput = JSON.parse(packResult.stdout);
const filename = packOutput[0]?.filename;
assert.ok(filename, 'npm pack should report the archive filename');
packedFixture = {
archivePath: path.join(directory, filename),
directory,
publishedPaths: new Set(
packOutput[0]?.files?.map(file => file.path) || []
),
};
return packedFixture;
}
function prepareLocalPackedProject(packageManager) {
if (localPackedProject) {
return localPackedProject;
}
const fixture = getPackedFixture();
const projectDirectory = path.join(fixture.directory, 'local-project');
const modulesDirectory = path.join(projectDirectory, 'node_modules');
const extractedDirectory = path.join(modulesDirectory, 'package');
const packageDirectory = path.join(modulesDirectory, 'ecc-universal');
const binDirectory = path.join(modulesDirectory, '.bin');
fs.mkdirSync(projectDirectory, { recursive: true });
fs.writeFileSync(
path.join(projectDirectory, 'package.json'),
`${JSON.stringify({ name: 'ecc-packed-smoke', private: true }, null, 2)}\n`
);
if (packageManager === 'yarn') {
// This empty fixture has no dependencies. Generate only its local lockfile
// so `yarn exec` can run the manually unpacked package in PR hardened mode.
run('yarn', ['install', '--mode=skip-build', '--no-immutable'], {
cwd: projectDirectory,
env: {
...process.env,
YARN_ENABLE_HARDENED_MODE: '0',
YARN_ENABLE_IMMUTABLE_INSTALLS: 'false',
YARN_ENABLE_NETWORK: '0',
},
});
}
fs.mkdirSync(modulesDirectory, { recursive: true });
run('tar', ['-xzf', fixture.archivePath, '-C', modulesDirectory], {
cwd: projectDirectory,
});
fs.renameSync(extractedDirectory, packageDirectory);
fs.mkdirSync(binDirectory, { recursive: true });
for (const executable of ['ecc', 'ecc-universal']) {
const scriptPath = path.join(packageDirectory, packageJson.bin[executable]);
fs.chmodSync(scriptPath, 0o755);
if (process.platform === 'win32') {
const cmdPath = path.join(binDirectory, `${executable}.cmd`);
const target = packageJson.bin[executable].replace(/\//g, '\\');
fs.writeFileSync(
cmdPath,
`@ECHO off\r\nnode "%~dp0\\..\\ecc-universal\\${target}" %*\r\n`
);
} else {
fs.symlinkSync(
path.join('..', 'ecc-universal', packageJson.bin[executable]),
path.join(binDirectory, executable)
);
}
}
localPackedProject = { binDirectory, projectDirectory };
return localPackedProject;
}
function getRunnerInvocation(packageManager, executable, args) {
const project = prepareLocalPackedProject(packageManager);
const localEnvironment = withPathPrefix(process.env, project.binDirectory);
switch (packageManager) {
case 'npm':
{
// npx --offline --package=<local.tgz> still resolves uncached
// transitive dependencies from the registry. Unpack the artifact and
// invoke npm's local executable runner so CI proves the packaged bin
// without depending on registry cache state.
return {
command: 'npm',
args: [
'exec',
'--offline',
'--package=./node_modules/ecc-universal',
'--',
executable,
...args,
],
cwd: project.projectDirectory,
env: { ...localEnvironment, npm_config_offline: 'true' },
};
}
case 'pnpm':
return {
command: 'pnpm',
args: ['exec', executable, ...args],
cwd: project.projectDirectory,
env: { ...localEnvironment, npm_config_offline: 'true' },
};
case 'yarn':
{
// Yarn dlx resolves transitive package metadata from the registry even
// when the package tarball and dependency archives are cached. For a
// hermetic pre-publish gate, execute the exact unpacked artifact through
// Yarn's runner with network disabled. A post-publish dlx smoke test is
// still required to validate registry metadata.
return {
command: 'yarn',
args: ['exec', executable, ...args],
env: {
...localEnvironment,
YARN_ENABLE_NETWORK: '0',
YARN_ENABLE_HARDENED_MODE: '0',
},
cwd: project.projectDirectory,
};
}
case 'bun':
{
// bunx has no strict offline install mode. Unpack the exact artifact
// locally and use --no-install so the smoke cannot reach the registry.
return {
command: 'bunx',
args: ['--no-install', executable, ...args],
env: localEnvironment,
cwd: project.projectDirectory,
};
}
default:
throw new Error(`Unsupported package manager: ${packageManager}`);
}
}
function launchPackedBinary(executable, args) {
const fixture = getPackedFixture();
const invocation = getRunnerInvocation(
activePackageManager,
executable,
args
);
return run(invocation.command, invocation.args, {
cwd: invocation.cwd || fixture.directory,
env: invocation.env,
});
}
console.log(`\n=== ECC universal packed binary tests (${activePackageManager}) ===\n`);
test('CI selects a supported package runner', () => {
assert.ok(
supportedPackageManagers.has(activePackageManager),
`CLAUDE_CODE_PACKAGE_MANAGER must be one of ${[...supportedPackageManagers].join(', ')}`
);
});
test('Windows package shims use one safely quoted command line', () => {
assert.deepStrictEqual(
getSpawnInvocation('npm', ['pack', '--pack-destination', 'C:\\Temp Dir'], 'win32'),
{
args: undefined,
command: 'npm.cmd pack --pack-destination "C:\\Temp Dir"',
shell: true,
}
);
assert.deepStrictEqual(
getSpawnInvocation('tar', ['-xzf', 'C:\\Temp Dir\\fixture.tgz'], 'win32'),
{
args: ['-xzf', 'C:\\Temp Dir\\fixture.tgz'],
command: 'tar',
}
);
assert.throws(
() => getSpawnInvocation('npm', ['pack', 'C:\\Temp & unsafe'], 'win32'),
/unsafe for cmd\.exe/
);
});
test('published package exposes ecc and ecc-universal through scripts/ecc.js', () => {
assert.strictEqual(packageJson.bin.ecc, 'scripts/ecc.js');
assert.strictEqual(packageJson.bin['ecc-universal'], 'scripts/ecc.js');
assert.deepStrictEqual(packageLock.packages[''].bin, packageJson.bin);
const fixture = getPackedFixture();
assert.ok(
fixture.publishedPaths.has('scripts/ecc.js'),
'npm package should publish the shared CLI target'
);
});
test('packed ecc-universal launches the guided Claude setup help', () => {
const result = launchPackedBinary('ecc-universal', ['setup', '--help']);
assert.match(result.stdout, /ECC guided setup/);
});
test('packed ecc-universal launches the guided multi-harness help', () => {
const result = launchPackedBinary(
'ecc-universal',
['install', '--guided', '--help']
);
assert.match(result.stdout, /ECC guided multi-harness install/);
assert.match(result.stdout, /Claude Code/);
assert.match(result.stdout, /Codex/);
assert.match(result.stdout, /Kimi/);
});
test('packed ecc alias launches the primary dispatcher', () => {
const result = launchPackedBinary('ecc', ['--help']);
assert.match(result.stdout, /ECC selective-install CLI/);
assert.match(result.stdout, /ecc install --guided/);
});
if (packedFixture) {
fs.rmSync(packedFixture.directory, { force: true, recursive: true });
}
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
+34 -14
View File
@@ -78,22 +78,42 @@ function main() {
assert.match(result.stdout, /feedback/);
}],
['delegates explicit install command', () => {
const result = runCli(['install', '--dry-run', '--json', 'typescript']);
assert.strictEqual(result.status, 0, result.stderr);
const payload = parseJson(result.stdout);
assert.strictEqual(payload.dryRun, true);
assert.strictEqual(payload.plan.mode, 'legacy-compat');
assert.deepStrictEqual(payload.plan.legacyLanguages, ['typescript']);
assert.ok(payload.plan.selectedModuleIds.includes('framework-language'));
const homeDir = createTempDir('ecc-cli-install-home-');
try {
const result = runCli(['install', '--dry-run', '--json', 'typescript'], {
env: {
CLAUDE_CONFIG_DIR: path.join(homeDir, '.claude'),
HOME: homeDir,
},
});
assert.strictEqual(result.status, 0, result.stderr);
const payload = parseJson(result.stdout);
assert.strictEqual(payload.dryRun, true);
assert.strictEqual(payload.plan.mode, 'legacy-compat');
assert.deepStrictEqual(payload.plan.legacyLanguages, ['typescript']);
assert.ok(payload.plan.selectedModuleIds.includes('framework-language'));
} finally {
fs.rmSync(homeDir, { force: true, recursive: true });
}
}],
['routes implicit top-level args to install', () => {
const result = runCli(['--dry-run', '--json', 'typescript']);
assert.strictEqual(result.status, 0, result.stderr);
const payload = parseJson(result.stdout);
assert.strictEqual(payload.dryRun, true);
assert.strictEqual(payload.plan.mode, 'legacy-compat');
assert.deepStrictEqual(payload.plan.legacyLanguages, ['typescript']);
assert.ok(payload.plan.selectedModuleIds.includes('framework-language'));
const homeDir = createTempDir('ecc-cli-install-home-');
try {
const result = runCli(['--dry-run', '--json', 'typescript'], {
env: {
CLAUDE_CONFIG_DIR: path.join(homeDir, '.claude'),
HOME: homeDir,
},
});
assert.strictEqual(result.status, 0, result.stderr);
const payload = parseJson(result.stdout);
assert.strictEqual(payload.dryRun, true);
assert.strictEqual(payload.plan.mode, 'legacy-compat');
assert.deepStrictEqual(payload.plan.legacyLanguages, ['typescript']);
assert.ok(payload.plan.selectedModuleIds.includes('framework-language'));
} finally {
fs.rmSync(homeDir, { force: true, recursive: true });
}
}],
['delegates plan command', () => {
const result = runCli(['plan', '--list-profiles', '--json']);
+37 -1
View File
@@ -6,7 +6,7 @@ const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync } = require('child_process');
const { execFileSync, spawnSync } = require('child_process');
const { applyInstallPlan } = require('../../scripts/lib/install/apply');
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'install-apply.js');
@@ -53,6 +53,33 @@ function run(args = [], options = {}) {
}
}
function runWithGuidedDispatcherFailure(failureMode) {
const root = createTempDir('install-apply-guided-failure-');
const preloadPath = path.join(root, 'preload.js');
const failureMessage = 'guided dispatcher failed\u001b[31m';
const replacement = failureMode === 'load'
? `throw new Error(${JSON.stringify(failureMessage)});`
: `return { main: () => Promise.reject(new Error(${JSON.stringify(failureMessage)})) };`;
fs.writeFileSync(preloadPath, `
const Module = require('module');
const originalLoad = Module._load;
Module._load = function(request, parent, isMain) {
if (request === './install-guided' && /install-apply\\.js$/.test(parent?.filename || '')) {
${replacement}
}
return originalLoad.call(this, request, parent, isMain);
};
`);
try {
return spawnSync(process.execPath, ['--require', preloadPath, SCRIPT, '--guided'], {
cwd: path.dirname(SCRIPT),
encoding: 'utf8',
});
} finally {
cleanup(root);
}
}
function test(name, fn) {
try {
fn();
@@ -80,6 +107,15 @@ function runTests() {
assert.ok(result.stdout.includes('--modules <id,id,...>'));
})) passed++; else failed++;
if (test('guided dispatcher reports sanitized load and rejection failures', () => {
for (const failureMode of ['load', 'reject']) {
const result = runWithGuidedDispatcherFailure(failureMode);
assert.strictEqual(result.status, 1);
assert.strictEqual(result.stdout, '');
assert.strictEqual(result.stderr, 'Error: guided dispatcher failed\n');
}
})) passed++; else failed++;
if (test('rejects mixing legacy languages with manifest profile flags', () => {
const result = run(['--profile', 'core', 'typescript']);
assert.strictEqual(result.code, 1);
+366
View File
@@ -0,0 +1,366 @@
'use strict';
const assert = require('assert');
const path = require('path');
const { spawnSync } = require('child_process');
const {
collectInteractiveOptions,
main,
parseArgs,
validateExecutionMode,
} = require('../../scripts/install-guided');
const {
normalizeGuidedInstallRequest,
} = require('../../scripts/lib/multi-harness-setup');
const repoRoot = path.join(__dirname, '..', '..');
const guidedPtyFixture = path.join(repoRoot, 'tests', 'fixtures', 'run-guided-install-pty.js');
let passed = 0;
let failed = 0;
async function test(name, fn) {
try {
await fn();
console.log(`${name}`);
passed += 1;
} catch (error) {
console.log(`${name}`);
console.log(` Error: ${error.message}`);
failed += 1;
}
}
function fakeTerminal(answers) {
const queue = [...answers];
let prompts = [];
return {
async question(prompt = '') {
prompts = [...prompts, prompt];
if (queue.length === 0) throw new Error('No fake answer available');
return queue.shift();
},
close() {},
get prompts() { return [...prompts]; },
};
}
function capture(isTTY = true) {
let value = '';
return {
isTTY,
write(chunk) { value += chunk; },
read() { return value; },
};
}
function quoteShellArgument(value) {
return `'${String(value).replace(/'/g, `'\\''`)}'`;
}
function runGuidedPtyFixture(answers) {
if (process.platform === 'win32') return null;
const command = [process.execPath, guidedPtyFixture];
const scriptArgs = process.platform === 'darwin'
? ['-q', '-e', '/dev/null', ...command]
: ['-q', '-e', '-c', command.map(quoteShellArgument).join(' '), '/dev/null'];
const pseudoTerminalCommand = ['script', ...scriptArgs]
.map(quoteShellArgument)
.join(' ');
const answerCommands = answers
.map(answer => `sleep 0.35; printf '%s\\n' ${quoteShellArgument(answer)}`)
.join('; ');
return spawnSync('sh', ['-c', `(${answerCommands}; sleep 0.1) | ${pseudoTerminalCommand}`], {
cwd: repoRoot,
encoding: 'utf8',
timeout: 15000,
});
}
(async () => {
console.log('\n=== Guided multi-harness CLI tests ===\n');
await test('parses repeatable harness flags and provider-specific choices', () => {
assert.deepStrictEqual(parseArgs([
'--harness', 'kimi', '--harness', 'claude,codex',
'--claude-scope', 'local', '--claude-hooks', 'minimal',
'--profile', 'developer', '--yes', '--dry-run', '--json',
]), {
allHarnesses: false,
claudeHooks: 'minimal',
claudeScope: 'local',
dryRun: true,
harnesses: ['kimi', 'claude,codex'],
help: false,
json: true,
profile: 'developer',
yes: true,
});
assert.throws(() => parseArgs(['--harness']), /Missing value.*--harness/);
assert.throws(() => parseArgs(['--nope']), /Unknown argument/);
assert.throws(
() => parseArgs(['--all-harnesses', '--harness', 'claude']),
/mutually exclusive/i
);
});
await test('supports every non-empty Claude, Codex, and Kimi selection combination', () => {
const combinations = [
['claude'], ['codex'], ['kimi'],
['claude', 'codex'], ['claude', 'kimi'], ['codex', 'kimi'],
['claude', 'codex', 'kimi'],
];
for (const harnesses of combinations) {
const parsed = parseArgs(harnesses.flatMap(id => ['--harness', id]));
assert.deepStrictEqual(parsed.harnesses, harnesses);
const request = normalizeGuidedInstallRequest({
...parsed,
claudeHooks: harnesses.includes('claude') ? 'standard' : undefined,
claudeScope: harnesses.includes('claude') ? 'user' : undefined,
profile: harnesses.includes('kimi') ? 'core' : undefined,
});
assert.deepStrictEqual(request.harnesses, harnesses);
}
});
await test('interactive selection reprompts and only asks relevant provider questions', async () => {
const output = capture();
const result = await collectInteractiveOptions(parseArgs([]), {
output,
terminal: fakeTerminal(['bogus', '1,3', '3', '2', '4']),
});
assert.deepStrictEqual(result.harnesses, ['claude', 'kimi']);
assert.strictEqual(result.claudeScope, 'local');
assert.strictEqual(result.claudeHooks, 'minimal');
assert.strictEqual(result.profile, 'security');
assert.match(output.read(), /Please choose/i);
assert.doesNotMatch(output.read(), /Codex.*scope/i);
});
await test('interactive prompts keep spacing, recommended defaults, and one visible confirmation', async () => {
const output = capture();
const terminal = fakeTerminal(['all', '1', '3', '2']);
const options = await collectInteractiveOptions(parseArgs([]), { output, terminal });
assert.deepStrictEqual(options.harnesses, ['claude', 'codex', 'kimi']);
assert.match(output.read(), /Advanced adapters[^\n]+\.\n\n\nWhere should Claude/);
assert.deepStrictEqual(terminal.prompts, [
'Choose one or more (for example 1,3 or all): ',
'Choose [Recommended: user] (one option only): ',
'Choose [Recommended: standard] (one option only): ',
'Choose [Recommended: core] (one option only): ',
]);
const confirmationOutput = capture();
const confirmationTerminal = fakeTerminal(['y']);
const code = await main([
'--harness', 'codex',
], {
applyPlan: async () => ({ status: 'complete', completed: [{ id: 'codex' }] }),
createPlan: async request => ({
request,
harnesses: [{ id: 'codex', channel: 'native-plugin', preview: {} }],
}),
interactive: true,
output: confirmationOutput,
terminal: confirmationTerminal,
showWelcome: () => {},
startSpinner: () => ({ stop() {} }),
});
assert.strictEqual(code, 0);
assert.deepStrictEqual(
confirmationTerminal.prompts,
['Apply ECC to these harnesses? [y/N]: ']
);
});
await test('real PTY shows every all-harness question and applies after visible yes', () => {
const result = runGuidedPtyFixture(['all', '1', '3', '2', 'y']);
if (result === null) return;
assert.strictEqual(result.status, 0, result.stderr);
const visible = `${result.stdout}${result.stderr}`
// eslint-disable-next-line no-control-regex
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
.replace(/\r/g, '');
const orderedPrompts = [
'Choose one or more (for example 1,3 or all):',
'Choose [Recommended: user] (one option only):',
'Choose [Recommended: standard] (one option only):',
'Choose [Recommended: core] (one option only):',
'Apply ECC to these harnesses? [y/N]:',
'PTY_WELCOME_SHOWN',
];
let previousIndex = -1;
for (const prompt of orderedPrompts) {
const promptIndex = visible.indexOf(prompt);
assert.ok(promptIndex > previousIndex, `missing or out-of-order PTY prompt: ${prompt}`);
previousIndex = promptIndex;
}
assert.doesNotMatch(visible, /install cancelled/i);
});
await test('non-interactive and JSON modes require complete explicit choices', () => {
assert.throws(
() => validateExecutionMode(parseArgs([]), false),
/--harness/i
);
assert.throws(
() => validateExecutionMode(parseArgs(['--harness', 'claude', '--json']), true),
/Claude.*scope.*hooks/i
);
assert.throws(
() => validateExecutionMode(parseArgs([
'--harness', 'claude', '--claude-scope', 'user', '--claude-hooks', 'standard', '--json',
]), true),
/--yes/i
);
});
await test('runs one preflight, one confirmation, and one apply for all selected harnesses', async () => {
const output = capture();
const terminal = fakeTerminal(['y']);
const events = [];
const code = await main([
'--harness', 'claude', '--harness', 'codex', '--harness', 'kimi',
'--claude-scope', 'user', '--claude-hooks', 'standard', '--profile', 'core',
], {
applyPlan: async plan => { events.push('apply'); return { status: 'complete', completed: plan.harnesses }; },
createPlan: async request => {
events.push('preflight');
return {
request,
harnesses: request.harnesses.map(id => ({ id, channel: id === 'kimi' ? 'managed-project' : 'native-plugin', preview: {} })),
};
},
interactive: true,
output,
terminal,
showWelcome: () => events.push('welcome'),
startSpinner: () => ({ stop: () => events.push('spinner:stop') }),
});
assert.strictEqual(code, 0);
assert.deepStrictEqual(events, ['preflight', 'apply', 'spinner:stop', 'welcome']);
assert.strictEqual(
terminal.prompts.filter(prompt => /Apply ECC to these harnesses\?/.test(prompt)).length,
1
);
});
await test('cancellation and dry-run perform no mutation or welcome', async () => {
for (const dryRun of [false, true]) {
const output = capture();
let applyCalls = 0;
let welcomeCalls = 0;
const args = [
'--harness', 'codex',
...(dryRun ? ['--dry-run'] : []),
];
const code = await main(args, {
applyPlan: async () => { applyCalls += 1; },
createPlan: async request => ({ request, harnesses: [{ id: 'codex', channel: 'native-plugin', preview: {} }] }),
interactive: true,
output,
terminal: fakeTerminal(dryRun ? [] : ['n']),
showWelcome: () => { welcomeCalls += 1; },
});
assert.strictEqual(code, 0);
assert.strictEqual(applyCalls, 0);
assert.strictEqual(welcomeCalls, 0);
}
});
await test('JSON mode emits one clean result document', async () => {
const output = capture(true);
const code = await main(['--harness', 'codex', '--yes', '--json'], {
applyPlan: async () => ({ status: 'complete', completed: [{ id: 'codex' }], retryHarnesses: [] }),
createPlan: async request => ({ request, harnesses: [{ id: 'codex', channel: 'native-plugin', preview: {} }] }),
interactive: true,
output,
showWelcome: () => { throw new Error('welcome must be suppressed'); },
});
assert.strictEqual(code, 0);
const value = JSON.parse(output.read());
assert.strictEqual(value.result.status, 'complete');
});
await test('help and failed apply paths are actionable', async () => {
const helpOutput = capture();
assert.strictEqual(await main(['--help'], { output: helpOutput }), 0);
assert.match(helpOutput.read(), /Advanced managed adapters/);
const output = capture();
const errorOutput = capture();
const code = await main(['--harness', 'codex', '--yes'], {
applyPlan: async () => ({
status: 'failed',
completed: [],
failure: { id: 'codex', message: 'verification failed' },
retryHarnesses: ['codex'],
}),
createPlan: async request => ({ request, harnesses: [{ id: 'codex', channel: 'native-plugin', preview: {} }] }),
errorOutput,
interactive: false,
output,
});
assert.strictEqual(code, 1);
assert.match(
errorOutput.read(),
/Retry with: ecc-universal install --guided --harness codex/
);
const jsonError = capture();
assert.strictEqual(await main(['--json'], {
errorOutput: jsonError,
interactive: false,
output: capture(false),
}), 1);
assert.strictEqual(JSON.parse(jsonError.read()).error.code, 'GUIDED_INSTALL_FAILED');
});
await test('retry command preserves unfinished provider-specific choices', async () => {
const output = capture(false);
const errorOutput = capture(false);
const code = await main([
'--harness', 'claude', '--harness', 'kimi',
'--claude-scope', 'local', '--claude-hooks', 'strict',
'--profile', 'developer', '--yes',
], {
applyPlan: async () => ({
status: 'failed',
completed: [],
failure: { id: 'claude', message: 'verification failed' },
retryHarnesses: ['claude', 'kimi'],
}),
createPlan: async request => ({
request,
harnesses: [
{ id: 'claude', channel: 'native-plugin', preview: {} },
{ id: 'kimi', channel: 'managed-project', preview: {} },
],
}),
errorOutput,
interactive: false,
output,
});
assert.strictEqual(code, 1);
assert.match(
errorOutput.read(),
/Retry with: ecc-universal install --guided --harness claude --harness kimi --claude-scope local --claude-hooks strict --profile developer/
);
});
await test('human-facing parser errors never echo terminal control bytes', async () => {
const errorOutput = capture();
const code = await main(['--harness', 'codex\u001b[31m'], {
errorOutput,
interactive: false,
output: capture(false),
});
assert.strictEqual(code, 1);
assert.ok(!errorOutput.read().includes('\u001b'));
assert.doesNotMatch(errorOutput.read(), /\[31m/);
});
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exitCode = failed > 0 ? 1 : 0;
})();
+1 -1
View File
@@ -52,7 +52,7 @@ function run(powerShellCommand, args = [], options = {}) {
env,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 10000,
timeout: 30000,
});
return { code: 0, stdout, stderr: '' };
+64 -3
View File
@@ -36,8 +36,8 @@ function runTests() {
'README should surface a top-level install decision section'
);
assert.ok(
readme.includes('**Recommended default:** install the Claude Code plugin'),
'README should name the recommended default install path'
readme.includes('**Recommended default:** run the guided Claude plugin setup'),
'README should name guided setup as the recommended default install path'
);
assert.ok(
readme.includes('**Do not stack install methods.**'),
@@ -49,6 +49,43 @@ function runTests() {
);
})) passed++; else failed++;
if (test('README leads with the idempotent guided plugin setup path', () => {
assert.ok(
readme.includes('npx ecc-universal setup'),
'README should lead new users to the package-name setup command'
);
assert.ok(
readme.includes('installs, updates, or safely moves `ecc@ecc`'),
'README should explain that rerunning guided setup reconciles existing installs'
);
assert.ok(
readme.includes('Claude Code owns these built-in commands'),
'README should distinguish provider-owned slash behavior from ECC setup behavior'
);
assert.ok(
readme.includes('`/ecc:configure-ecc`'),
'README should document the installed namespaced reconfiguration skill'
);
assert.ok(
readme.includes('available only after the plugin is installed'),
'README should not imply the namespaced skill can perform a first install'
);
assert.ok(
readme.includes('currently configures the Claude Code plugin'),
'README should not imply that the current setup wizard installs every ECC harness'
);
})) passed++; else failed++;
if (test('README documents modern package-runner alternatives', () => {
assert.ok(readme.includes('pnpm dlx ecc-universal setup'));
assert.ok(readme.includes('yarn dlx ecc-universal setup'));
assert.ok(readme.includes('bunx ecc-universal setup'));
assert.ok(
readme.includes('Yarn Classic 1 does not provide `yarn dlx`'),
'README should not advertise the modern Yarn command to Yarn Classic users'
);
})) passed++; else failed++;
if (test('README documents reset and uninstall flow', () => {
assert.ok(
readme.includes('### Reset / Uninstall ECC'),
@@ -101,7 +138,7 @@ function runTests() {
'README should surface component discovery before install steps'
);
assert.ok(
readme.includes('npx ecc consult "security reviews" --target claude'),
readme.includes('npx ecc-universal consult "security reviews" --target claude'),
'README should document the packaged consult command'
);
assert.ok(
@@ -110,6 +147,30 @@ function runTests() {
);
})) passed++; else failed++;
if (test('README never invokes the unrelated ecc npm package', () => {
assert.ok(
!/\bnpx ecc\s/.test(readme),
'README one-shot commands should use the published ecc-universal package name'
);
})) passed++; else failed++;
if (test('README gives the native guided Codex and managed Kimi dry-run paths', () => {
assert.ok(
readme.includes('npx ecc-universal install --guided --harness codex --dry-run'),
'README should verify Codex through the native guided reconciler'
);
assert.ok(
!readme.includes('npx ecc-universal install --profile core --target codex --dry-run'),
'README should not present the legacy managed Codex adapter as the native lifecycle'
);
assert.ok(
readme.includes('npx ecc-universal install --profile core --target kimi --dry-run')
);
for (const target of ['cursor', 'gemini', 'opencode', 'codebuddy', 'joycode', 'qwen', 'zed', 'hermes', 'openclaw']) {
assert.ok(readme.includes(`\`${target}\``), `README should name the ${target} target`);
}
})) passed++; else failed++;
if (test('README documents Cursor agent namespace and loading caveat', () => {
assert.ok(
readme.includes('`.cursor/agents/ecc-*.md`'),
+24 -5
View File
@@ -237,9 +237,11 @@ function main() {
assert.ok(localModelPath.includes('assets/images/sponsors/moonshot.png'));
assert.ok(localModelPath.includes('assets/images/community/ecc-tools-mark.svg'));
assert.match(readme, /install\.sh --target kimi --profile minimal/);
assert.match(readme, /npx ecc doctor --target kimi/);
assert.match(readme, /\.kimi\/AGENTS\.md/);
assert.match(readme, /\.kimi\/skills\//);
assert.match(readme, /npx ecc-universal doctor --target kimi/);
assert.match(readme, /\.kimi-code\/AGENTS\.md/);
assert.match(readme, /\.kimi-code\/skills\//);
assert.match(readme, /~\/\.kimi-code\/config\.toml/);
assert.match(readme, /Kimi Code 0\.31/);
assertExactHref(
readme,
'https://moonshotai.github.io/kimi-cli/en/configuration/providers.html'
@@ -293,6 +295,14 @@ function main() {
assert.ok(relativeDestinations.every(destination => (
!/^\.(?:claude|codex|cursor|gemini|hermes|opencode|openclaw|qwen|zed)\//.test(destination)
)));
assert.ok(!plan.operations.some(operation => operation.moduleId === 'hooks-runtime'));
fs.mkdirSync(path.join(projectDir, '.kimi-code'), { recursive: true });
fs.writeFileSync(
path.join(projectDir, '.kimi-code', 'mcp.json'),
`${JSON.stringify({ mcpServers: { existing: { command: 'keep-me' } } }, null, 2)}\n`,
'utf8'
);
const apply = spawnSync(
process.execPath,
@@ -313,8 +323,17 @@ function main() {
);
assert.strictEqual(apply.status, 0, apply.stderr);
assert.strictEqual(JSON.parse(apply.stdout).result.target, 'kimi');
assert.ok(fs.existsSync(path.join(projectDir, '.kimi', 'AGENTS.md')));
assert.ok(fs.readdirSync(path.join(projectDir, '.kimi', 'skills')).length > 0);
assert.strictEqual(targetRoot, path.join(fs.realpathSync(projectDir), '.kimi-code'));
assert.ok(fs.existsSync(path.join(projectDir, '.kimi-code', 'AGENTS.md')));
assert.ok(fs.readdirSync(path.join(projectDir, '.kimi-code', 'skills')).length > 0);
assert.ok(fs.existsSync(path.join(projectDir, '.kimi-code', 'mcp.json')));
const mcpConfig = JSON.parse(
fs.readFileSync(path.join(projectDir, '.kimi-code', 'mcp.json'), 'utf8')
);
assert.strictEqual(mcpConfig.mcpServers.existing.command, 'keep-me');
assert.ok(mcpConfig.mcpServers['chrome-devtools']);
assert.ok(!fs.existsSync(path.join(projectDir, '.kimi')));
assert.ok(!fs.existsSync(path.join(homeDir, '.kimi-code', 'config.toml')));
const doctor = spawnSync(
process.execPath,
@@ -55,6 +55,7 @@ function buildExpectedPublishPaths(repoRoot) {
"scripts/sessions-cli.js",
"scripts/work-items.js",
"scripts/install-apply.js",
"scripts/install-guided.js",
"scripts/install-plan.js",
"scripts/ito.js",
"scripts/list-installed.js",
@@ -72,7 +73,9 @@ function buildExpectedPublishPaths(repoRoot) {
"scripts/repair.js",
"scripts/harness-adapter-compliance.js",
"scripts/session-inspect.js",
"scripts/setup.js",
"scripts/uninstall.js",
"scripts/welcome.js",
"scripts/gemini-adapt-agents.js",
"scripts/sync-ecc-to-codex.sh",
"scripts/codex/check-plugin-cache.js",
@@ -163,6 +166,7 @@ function main() {
"scripts/work-items.js",
"scripts/platform-audit.js",
"scripts/sync-ecc-to-codex.sh",
"scripts/setup.js",
"scripts/codex/check-plugin-cache.js",
".gemini/GEMINI.md",
".qwen/QWEN.md",
+47
View File
@@ -21,6 +21,8 @@ const ciWorkflowPath = path.join(__dirname, '..', '..', '.github', 'workflows',
const releaseWorkflowSource = fs.readFileSync(releaseWorkflowPath, 'utf8');
const reusableReleaseWorkflowSource = fs.readFileSync(reusableReleaseWorkflowPath, 'utf8');
const ciWorkflowSource = fs.readFileSync(ciWorkflowPath, 'utf8');
const rootReadmePath = path.join(__dirname, '..', '..', 'README.md');
const rootReadmeSource = fs.readFileSync(rootReadmePath, 'utf8');
const normalizedCiWorkflowSource = ciWorkflowSource.replace(/\r\n/g, '\n');
function test(name, fn) {
@@ -91,6 +93,51 @@ function runTests() {
source.includes('update_latest_release_heading "$ROOT_ZH_CN_README_FILE"'),
'release.sh should update localized latest-release headings that plugin-manifest.test.js verifies'
);
assert.ok(
source.includes('Error: could not update release heading for v${oldVersion} in ${file}'),
'release.sh should fail loudly when a required release heading is absent'
);
})) passed++; else failed++;
if (test('a 2.2 bump preserves historical root README release headings', () => {
const historicalHeading = rootReadmeSource.match(/^### v2\.0\.0:.*$/m);
assert.ok(historicalHeading, 'README fixture should contain the historical v2.0.0 heading');
assert.ok(
source.includes('const oldVersion = process.argv[3]'),
'release heading sync should receive the version being replaced'
);
assert.ok(
source.includes('escape(oldVersion)'),
'release heading sync should target the current release version exactly'
);
assert.ok(
!source.includes('/^### v[0-9]+\\.[0-9]+\\.[0-9]+'),
'release heading sync must not relabel the first version-shaped heading as the new release'
);
const oldVersion = '2.1.0';
const nextVersion = '2.2.0';
const escapedOldVersion = oldVersion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const simulated = rootReadmeSource.replace(
new RegExp(`^### v${escapedOldVersion}( .*)$`, 'm'),
`### v${nextVersion}$1`
);
assert.ok(
simulated.includes(historicalHeading[0]),
'syncing the current release must leave the historical v2.0.0 heading unchanged'
);
})) passed++; else failed++;
if (test('release script rejects same-version reruns with direct tag guidance', () => {
assert.ok(
source.includes('if [[ "$OLD_VERSION" == "$VERSION" ]]'),
'release.sh should detect metadata that already declares the requested version'
);
assert.ok(
source.includes('echo " git tag \\"v$VERSION\\""') &&
source.includes('echo " git push origin \\"v$VERSION\\""'),
'same-version guidance should point maintainers to the tag-driven publish path'
);
})) passed++; else failed++;
if (test('release workflows mark prerelease tags as GitHub prereleases', () => {
+63
View File
@@ -0,0 +1,63 @@
'use strict';
const assert = require('assert');
const {
validateInteractiveJsonOptions,
} = require('../../scripts/setup');
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(`${name}`);
passed += 1;
} catch (error) {
console.log(`${name}`);
console.log(` Error: ${error.message}`);
failed += 1;
}
}
console.log('\n=== ECC setup option contract tests ===\n');
test('rejects interactive JSON when wizard choices are missing', () => {
assert.throws(
() => validateInteractiveJsonOptions({
hooks: undefined,
json: true,
mode: 'claude-plugin',
scope: undefined,
}, true),
/json.*scope.*hooks/i
);
});
test('allows fully specified JSON and ordinary interactive wizard use', () => {
assert.doesNotThrow(() => validateInteractiveJsonOptions({
dryRun: true,
hooks: 'strict',
json: true,
mode: 'claude-plugin',
scope: 'project',
}, true));
assert.doesNotThrow(() => validateInteractiveJsonOptions({
hooks: undefined,
json: false,
mode: undefined,
scope: undefined,
}, true));
assert.throws(() => validateInteractiveJsonOptions({
dryRun: false,
hooks: 'strict',
json: true,
mode: 'claude-plugin',
scope: 'project',
yes: false,
}, true), /json.*yes/i);
});
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
+874
View File
@@ -0,0 +1,874 @@
'use strict';
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const repoRoot = path.join(__dirname, '..', '..');
const setupScript = path.join(repoRoot, 'scripts', 'setup.js');
const eccScript = path.join(repoRoot, 'scripts', 'ecc.js');
const fakeClaudeScript = path.join(repoRoot, 'tests', 'fixtures', 'fake-claude-plugin.js');
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(`${name}`);
passed += 1;
} catch (error) {
console.log(`${name}`);
console.log(` Error: ${error.message}`);
failed += 1;
}
}
function createFixture(state = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc setup cli '));
const homeDir = path.join(root, 'home');
const configDir = path.join(root, 'config');
const projectRoot = path.join(root, 'project');
const binDir = path.join(root, 'bin');
const statePath = path.join(root, 'state.json');
const callsPath = path.join(root, 'calls.jsonl');
for (const dir of [homeDir, configDir, projectRoot, binDir]) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(statePath, `${JSON.stringify({
plugins: [],
marketplaces: [],
failures: [],
...state,
}, null, 2)}\n`);
const launcher = path.join(binDir, process.platform === 'win32' ? 'claude.cmd' : 'claude');
const source = process.platform === 'win32'
? `@echo off\r\n"${process.execPath}" "${fakeClaudeScript}" %*\r\n`
: `#!/bin/sh\nexec "${process.execPath}" "${fakeClaudeScript}" "$@"\n`;
fs.writeFileSync(launcher, source);
if (process.platform !== 'win32') fs.chmodSync(launcher, 0o755);
return {
root,
homeDir,
configDir,
projectRoot,
binDir,
statePath,
callsPath,
};
}
function runSetup(fixture, args) {
return spawnSync(process.execPath, [setupScript, ...args], {
cwd: fixture.projectRoot,
env: {
...process.env,
HOME: fixture.homeDir,
USERPROFILE: fixture.homeDir,
CLAUDE_CONFIG_DIR: fixture.configDir,
PATH: `${fixture.binDir}${path.delimiter}${process.env.PATH || ''}`,
ECC_TEST_CLAUDE_STATE: fixture.statePath,
ECC_TEST_CLAUDE_CALLS: fixture.callsPath,
},
encoding: 'utf8',
timeout: 15000,
});
}
function quoteShellArgument(value) {
return `'${String(value).replace(/'/g, `'\\''`)}'`;
}
function runInteractiveEccSetup(fixture, options = {}) {
if (process.platform === 'win32') {
return null;
}
const args = options.args || ['--dry-run'];
const answers = options.answers || ['3', '3'];
const command = [
process.execPath,
eccScript,
'setup',
...args,
];
const scriptArgs = process.platform === 'darwin'
? ['-q', '-e', '/dev/null', ...command]
: [
'-q',
'-e',
'-c',
command.map(quoteShellArgument).join(' '),
'/dev/null',
];
const pseudoTerminalCommand = ['script', ...scriptArgs]
.map(quoteShellArgument)
.join(' ');
const answerCommands = answers
.map(answer => `sleep 0.5; printf '%s\\n' ${quoteShellArgument(answer)}`)
.join('; ');
return spawnSync('sh', [
'-c',
`(${answerCommands}; sleep 0.1) | ${pseudoTerminalCommand}`,
], {
cwd: fixture.projectRoot,
env: {
...process.env,
HOME: fixture.homeDir,
USERPROFILE: fixture.homeDir,
CLAUDE_CONFIG_DIR: fixture.configDir,
PATH: `${fixture.binDir}${path.delimiter}${process.env.PATH || ''}`,
ECC_TEST_CLAUDE_STATE: fixture.statePath,
ECC_TEST_CLAUDE_CALLS: fixture.callsPath,
},
encoding: 'utf8',
timeout: 15000,
});
}
function readCalls(fixture) {
if (!fs.existsSync(fixture.callsPath)) return [];
return fs.readFileSync(fixture.callsPath, 'utf8')
.trim()
.split(/\r?\n/)
.filter(Boolean)
.map(line => JSON.parse(line));
}
function hasMutation(fixture) {
return readCalls(fixture).some(argv => !(
argv.join(' ') === 'plugin list --json'
|| argv.join(' ') === 'plugin marketplace list --json'
));
}
const SETUP_SPINNER_PATTERN = /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]\s+Applying ECC setup/;
function assertNoSetupSpinner(output) {
assert.doesNotMatch(output, SETUP_SPINNER_PATTERN);
}
function assertSetupSpinnerLifecycle(output, outcomePattern) {
const spinnerIndex = output.search(SETUP_SPINNER_PATTERN);
const clearIndex = output.indexOf('\x1b[2K', spinnerIndex);
const outcomeIndex = output.search(outcomePattern);
assert.ok(spinnerIndex >= 0, 'confirmed interactive apply should start the setup spinner');
assert.ok(clearIndex > spinnerIndex, 'setup spinner should clear its terminal line');
assert.ok(outcomeIndex > clearIndex, 'setup spinner should clear before the final outcome');
const visibleOutput = output
// eslint-disable-next-line no-control-regex
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
.replace(/\r/g, '');
assert.match(
visibleOutput,
/\[y\/N\] (?:y|yes)\n[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]\s+Applying ECC setup/,
'setup spinner should be the first visible status after confirmation'
);
assertNoSetupSpinner(output.slice(outcomeIndex));
}
function withFixture(state, fn) {
const fixture = createFixture(state);
try {
fn(fixture);
} finally {
fs.rmSync(fixture.root, { recursive: true, force: true });
}
}
console.log('\n=== ECC setup CLI tests ===\n');
test('fresh non-interactive plugin setup requires an explicit scope', () => {
withFixture({}, fixture => {
const result = runSetup(fixture, [
'--mode', 'claude-plugin',
'--hooks', 'standard',
'--yes',
]);
assert.strictEqual(result.status, 1);
assert.match(result.stderr, /--scope/i);
assert.strictEqual(hasMutation(fixture), false);
});
});
test('an existing install without --scope updates its detected scope', () => {
withFixture({
plugins: [{ id: 'ecc@ecc', scope: 'project', enabled: true, version: '1.9.0' }],
marketplaces: [{
name: 'ecc',
source: 'github',
repo: 'affaan-m/ECC',
scope: 'project',
}],
}, fixture => {
const result = runSetup(fixture, [
'--mode', 'claude-plugin',
'--hooks', 'strict',
'--yes',
'--json',
]);
assert.strictEqual(result.status, 0, result.stderr);
const payload = JSON.parse(result.stdout);
assert.strictEqual(payload.action, 'updated');
assert.strictEqual(payload.scope, 'project');
assertNoSetupSpinner(`${result.stdout}${result.stderr}`);
assert.ok(readCalls(fixture).some(argv => (
JSON.stringify(argv) === JSON.stringify([
'plugin', 'update', 'ecc@ecc', '--scope', 'project',
])
)));
});
});
test('invalid plugin scopes and hook preferences are rejected before inventory', () => {
withFixture({}, fixture => {
for (const args of [
['--scope', 'global', '--hooks', 'standard'],
['--scope', 'user', '--hooks', 'aggressive'],
]) {
const result = runSetup(fixture, [
'--mode', 'claude-plugin',
...args,
'--yes',
]);
assert.strictEqual(result.status, 1);
assert.match(result.stderr, /invalid/i);
}
assert.deepStrictEqual(readCalls(fixture), []);
});
});
test('non-TTY mutation requires --yes', () => {
withFixture({}, fixture => {
const result = runSetup(fixture, [
'--mode', 'claude-plugin',
'--scope', 'user',
'--hooks', 'standard',
]);
assert.strictEqual(result.status, 1);
assert.match(result.stderr, /--yes/i);
assert.strictEqual(hasMutation(fixture), false);
});
});
test('dry-run JSON emits JSON only and reads inventory without mutation', () => {
withFixture({}, fixture => {
const result = runSetup(fixture, [
'--mode', 'claude-plugin',
'--scope', 'local',
'--hooks', 'minimal',
'--dry-run',
'--json',
]);
assert.strictEqual(result.status, 0, result.stderr);
const payload = JSON.parse(result.stdout);
assert.strictEqual(result.stdout.trim(), JSON.stringify(payload, null, 2));
assert.strictEqual(payload.action, 'would-install');
assert.strictEqual(payload.scope, 'local');
assertNoSetupSpinner(`${result.stdout}${result.stderr}`);
assert.strictEqual(hasMutation(fixture), false);
});
});
test('setup automatically migrates an existing install to the selected scope and hooks', () => {
withFixture({
plugins: [{ id: 'ecc@ecc', scope: 'local', enabled: true, version: '1.9.0' }],
marketplaces: [{
name: 'ecc',
source: 'github',
repo: 'affaan-m/ECC',
scope: 'local',
}],
}, fixture => {
const result = runSetup(fixture, [
'--mode', 'claude-plugin',
'--scope', 'user',
'--hooks', 'minimal',
'--yes',
'--json',
]);
assert.strictEqual(result.status, 0, result.stderr);
const payload = JSON.parse(result.stdout);
assert.strictEqual(payload.action, 'migrated');
assert.strictEqual(payload.sourceScope, 'local');
assert.strictEqual(payload.scope, 'user');
assert.strictEqual(payload.hooks, 'minimal');
const calls = readCalls(fixture);
assert.ok(calls.some(argv => (
argv.join(' ') === 'plugin install ecc@ecc --scope user'
+ ' --config hooks_enabled=true --config hook_profile=minimal'
)));
assert.ok(calls.some(argv => (
argv.join(' ') === 'plugin uninstall ecc@ecc --scope local --keep-data'
)));
assert.ok(!calls.flat().includes('--prune'));
const state = JSON.parse(fs.readFileSync(fixture.statePath, 'utf8'));
assert.deepStrictEqual(state.plugins, [{
id: 'ecc@ecc',
scope: 'user',
enabled: true,
version: '2.0.0',
}]);
const settings = JSON.parse(
fs.readFileSync(path.join(fixture.configDir, 'settings.json'), 'utf8')
);
assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hooks_enabled, true);
assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hook_profile, 'minimal');
});
});
test('setup resumes a safe two-scope migration without requiring --move-scope', () => {
withFixture({
plugins: [
{ id: 'ecc@ecc', scope: 'local', enabled: true, version: '1.9.0' },
{ id: 'ecc@ecc', scope: 'user', enabled: true, version: '2.0.0' },
],
marketplaces: [{
name: 'ecc',
source: 'github',
repo: 'affaan-m/ECC',
scope: 'user',
}],
}, fixture => {
const result = runSetup(fixture, [
'--mode', 'claude-plugin',
'--scope', 'user',
'--hooks', 'minimal',
'--yes',
'--json',
]);
assert.strictEqual(result.status, 0, result.stderr);
const payload = JSON.parse(result.stdout);
assert.strictEqual(payload.action, 'resumed');
assert.strictEqual(payload.sourceScope, 'local');
assert.strictEqual(payload.scope, 'user');
assert.ok(readCalls(fixture).some(argv => (
argv.join(' ') === 'plugin uninstall ecc@ecc --scope local --keep-data'
)));
});
});
test('all interrupted migration and hook combinations resume without reinstalling', () => {
const scopes = ['user', 'project', 'local'];
const hooks = ['off', 'minimal', 'standard', 'strict'];
for (const sourceScope of scopes) {
for (const destinationScope of scopes.filter(scope => scope !== sourceScope)) {
for (const hookMode of hooks) {
withFixture({
plugins: [
{ id: 'ecc@ecc', scope: sourceScope, enabled: true, version: '1.9.0' },
{ id: 'ecc@ecc', scope: destinationScope, enabled: true, version: '2.0.0' },
],
marketplaces: [{
name: 'ecc',
source: 'github',
repo: 'affaan-m/ECC',
scope: destinationScope,
}],
}, fixture => {
const result = runSetup(fixture, [
'--mode', 'claude-plugin',
'--scope', destinationScope,
'--hooks', hookMode,
'--yes',
'--json',
]);
assert.strictEqual(result.status, 0, result.stderr);
const payload = JSON.parse(result.stdout);
assert.strictEqual(payload.action, 'resumed');
assert.strictEqual(payload.sourceScope, sourceScope);
assert.strictEqual(payload.scope, destinationScope);
assert.strictEqual(payload.hooks, hookMode);
const calls = readCalls(fixture);
assert.ok(!calls.some(argv => argv[1] === 'install'));
assert.ok(calls.some(argv => (
argv.join(' ') === `plugin uninstall ecc@ecc --scope ${sourceScope} --keep-data`
)));
const state = JSON.parse(fs.readFileSync(fixture.statePath, 'utf8'));
assert.deepStrictEqual(state.plugins, [{
id: 'ecc@ecc',
scope: destinationScope,
enabled: true,
version: '2.0.0',
}]);
const settings = JSON.parse(
fs.readFileSync(path.join(fixture.configDir, 'settings.json'), 'utf8')
);
const stored = settings.pluginConfigs['ecc@ecc'].options;
assert.strictEqual(stored.hooks_enabled, hookMode !== 'off');
assert.strictEqual(
stored.hook_profile,
hookMode === 'off' ? 'standard' : hookMode
);
});
}
}
}
});
test('--move-scope remains explicit about its destination', () => {
withFixture({
plugins: [{ id: 'ecc@ecc', scope: 'user', enabled: true, version: '1.9.0' }],
}, fixture => {
const result = runSetup(fixture, [
'--mode', 'claude-plugin',
'--move-scope',
'--yes',
'--json',
]);
assert.strictEqual(result.status, 1);
assert.match(result.stderr, /scope/i);
assert.strictEqual(hasMutation(fixture), false);
});
});
test('destination-only --move-scope is an idempotent first call', () => {
withFixture({
plugins: [{ id: 'ecc@ecc', scope: 'local', enabled: true, version: '2.0.0' }],
}, fixture => {
const result = runSetup(fixture, [
'--mode', 'claude-plugin',
'--scope', 'local',
'--move-scope',
'--yes',
'--json',
]);
assert.strictEqual(result.status, 0, result.stderr);
const payload = JSON.parse(result.stdout);
assert.strictEqual(payload.action, 'already-migrated');
assert.strictEqual(payload.scope, 'local');
assert.strictEqual(hasMutation(fixture), false);
});
});
test('destination-only --move-scope applies explicit hook preferences', () => {
withFixture({
plugins: [{ id: 'ecc@ecc', scope: 'local', enabled: true, version: '2.0.0' }],
}, fixture => {
const result = runSetup(fixture, [
'--mode', 'claude-plugin',
'--scope', 'local',
'--move-scope',
'--hooks', 'strict',
'--yes',
'--json',
]);
assert.strictEqual(result.status, 0, result.stderr);
const payload = JSON.parse(result.stdout);
assert.strictEqual(payload.action, 'already-migrated');
assert.strictEqual(payload.preferencesUpdated, true);
const settings = JSON.parse(
fs.readFileSync(path.join(fixture.configDir, 'settings.json'), 'utf8')
);
assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hooks_enabled, true);
assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hook_profile, 'strict');
});
});
test('migration dry-run JSON exposes ordered actions without mutation', () => {
withFixture({
plugins: [{ id: 'ecc@ecc', scope: 'user', enabled: true, version: '1.9.0' }],
marketplaces: [{
name: 'ecc',
source: 'github',
repo: 'affaan-m/ECC',
scope: 'user',
}],
}, fixture => {
const result = runSetup(fixture, [
'--mode', 'claude-plugin',
'--scope', 'project',
'--dry-run',
'--json',
]);
assert.strictEqual(result.status, 0, result.stderr);
const payload = JSON.parse(result.stdout);
assert.strictEqual(payload.action, 'would-migrate');
assert.strictEqual(payload.dryRun, true);
assert.deepStrictEqual(payload.plannedActions.slice(-4), [
['plugin', 'list', '--json'],
['plugin', 'list', '--json'],
['plugin', 'uninstall', 'ecc@ecc', '--scope', 'user', '--keep-data'],
['plugin', 'list', '--json'],
]);
assert.strictEqual(hasMutation(fixture), false);
});
});
test('migration JSON failures retain phase, scopes, and exact recovery', () => {
withFixture({
plugins: [{ id: 'ecc@ecc', scope: 'user', enabled: true, version: '1.9.0' }],
marketplaces: [{
name: 'ecc',
source: 'github',
repo: 'affaan-m/ECC',
scope: 'user',
}],
failures: [{
argv: ['plugin', 'uninstall', 'ecc@ecc', '--scope', 'user', '--keep-data'],
status: 9,
stderr: 'uninstall failed',
times: 1,
}],
}, fixture => {
const result = runSetup(fixture, [
'--mode', 'claude-plugin',
'--scope', 'project',
'--move-scope',
'--yes',
'--json',
]);
assert.strictEqual(result.status, 1);
assert.strictEqual(result.stdout, '');
const payload = JSON.parse(result.stderr);
assert.strictEqual(payload.error.phase, 'source-uninstall');
assert.deepStrictEqual([...payload.error.observedScopes].sort(), ['project', 'user']);
assert.deepStrictEqual(payload.error.recovery, [
'claude plugin uninstall ecc@ecc --scope user --keep-data',
'ecc setup --mode claude-plugin --scope project --move-scope --yes',
]);
});
});
test('help explains native scope names in user-facing language', () => {
const result = spawnSync(process.execPath, [setupScript, '--help'], {
cwd: repoRoot,
encoding: 'utf8',
});
assert.strictEqual(result.status, 0, result.stderr);
assert.match(result.stdout, /(?:user.{0,80}global|global.{0,80}user)/is);
assert.match(result.stdout, /(?:project.{0,80}shared|shared.{0,80}project)/is);
assert.match(result.stdout, /(?:local.{0,80}private|private.{0,80}local)/is);
assert.match(result.stdout, /--hooks off\|minimal\|standard\|strict/);
assert.match(result.stdout, /--move-scope/);
});
test('ecc setup delegates to the focused setup command', () => {
const result = spawnSync(process.execPath, [eccScript, 'setup', '--help'], {
cwd: repoRoot,
encoding: 'utf8',
timeout: 15000,
});
assert.strictEqual(result.status, 0, result.stderr);
assert.match(result.stdout, /ECC (guided )?setup/i);
assert.match(result.stdout, /claude-plugin/);
});
test('ecc setup preserves a real terminal for the interactive wizard', () => {
if (process.platform === 'win32') return;
withFixture({}, fixture => {
const result = runInteractiveEccSetup(fixture);
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.ifError(result.error);
assert.match(result.stdout, /Where should Claude enable ecc@ecc\?/);
assert.match(result.stdout, /How should ECC hooks run\?/);
assert.doesNotMatch(result.stdout, /Interactive setup requires a terminal/);
assertNoSetupSpinner(`${result.stdout}${result.stderr}`);
});
});
test('confirmed interactive apply starts immediately and clears the spinner on success', () => {
if (process.platform === 'win32') return;
withFixture({}, fixture => {
const result = runInteractiveEccSetup(fixture, {
args: [],
answers: ['2', '2', 'y'],
});
assert.ifError(result.error);
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
assertSetupSpinnerLifecycle(
`${result.stdout}${result.stderr}`,
/ECC installed ecc@ecc at project scope/
);
});
});
test('confirmed interactive apply clears and stops the spinner when apply throws', () => {
if (process.platform === 'win32') return;
withFixture({
failures: [{
argv: [
'plugin', 'marketplace', 'add',
'https://github.com/affaan-m/ECC',
'--scope', 'user',
],
status: 8,
stderr: 'injected apply failure',
times: 1,
}],
}, fixture => {
const result = runInteractiveEccSetup(fixture, {
args: [],
answers: ['1', '3', 'yes'],
});
assert.ifError(result.error);
assert.strictEqual(result.status, 1, `${result.stdout}\n${result.stderr}`);
assertSetupSpinnerLifecycle(
`${result.stdout}${result.stderr}`,
/Error: Claude Code command failed: injected apply failure/
);
});
});
test('all interactive scope and hook choices install and persist the selected configuration', () => {
if (process.platform === 'win32') return;
const scopes = ['user', 'project', 'local'];
const hooks = ['off', 'minimal', 'standard', 'strict'];
for (const [scopeIndex, scope] of scopes.entries()) {
for (const [hookIndex, hookMode] of hooks.entries()) {
withFixture({}, fixture => {
const result = runInteractiveEccSetup(fixture, {
args: [],
answers: [String(scopeIndex + 1), String(hookIndex + 1), 'y'],
});
assert.ifError(result.error);
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.match(result.stdout, new RegExp(`ECC installed ecc@ecc at ${scope} scope`));
assert.match(result.stdout, new RegExp(`Hook preference: ${hookMode}`));
const state = JSON.parse(fs.readFileSync(fixture.statePath, 'utf8'));
assert.deepStrictEqual(state.plugins, [{
id: 'ecc@ecc',
scope,
enabled: true,
version: '2.0.0',
}]);
const settings = JSON.parse(
fs.readFileSync(path.join(fixture.configDir, 'settings.json'), 'utf8')
);
const stored = settings.pluginConfigs['ecc@ecc'].options;
assert.strictEqual(stored.hooks_enabled, hookMode !== 'off');
assert.strictEqual(stored.hook_profile, hookMode === 'off' ? 'standard' : hookMode);
});
}
}
});
test('all interactive choices from an existing install update or migrate to the selected configuration', () => {
if (process.platform === 'win32') return;
const scopes = ['user', 'project', 'local'];
const hooks = ['off', 'minimal', 'standard', 'strict'];
for (const [sourceIndex, sourceScope] of scopes.entries()) {
for (const [selectedIndex, selectedScope] of scopes.entries()) {
for (const [hookIndex, hookMode] of hooks.entries()) {
withFixture({
plugins: [{ id: 'ecc@ecc', scope: sourceScope, enabled: true, version: '1.9.0' }],
marketplaces: [{
name: 'ecc',
source: 'github',
repo: 'affaan-m/ECC',
scope: sourceScope,
}],
}, fixture => {
const result = runInteractiveEccSetup(fixture, {
args: [],
answers: [String(selectedIndex + 1), String(hookIndex + 1), 'y'],
});
assert.ifError(result.error);
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
const expectedAction = sourceIndex === selectedIndex ? 'updated' : 'migrated';
const expectedConfirmation = sourceIndex === selectedIndex ? 'Apply' : 'Migrate';
assert.match(
result.stdout,
new RegExp(`${expectedConfirmation} claude-plugin setup at ${selectedScope} scope`)
);
assert.match(
result.stdout,
new RegExp(`ECC ${expectedAction} ecc@ecc at ${selectedScope} scope`)
);
assert.match(result.stdout, new RegExp(`Hook preference: ${hookMode}`));
const state = JSON.parse(fs.readFileSync(fixture.statePath, 'utf8'));
assert.deepStrictEqual(state.plugins, [{
id: 'ecc@ecc',
scope: selectedScope,
enabled: true,
version: '2.0.0',
}]);
const settings = JSON.parse(
fs.readFileSync(path.join(fixture.configDir, 'settings.json'), 'utf8')
);
const stored = settings.pluginConfigs['ecc@ecc'].options;
assert.strictEqual(stored.hooks_enabled, hookMode !== 'off');
assert.strictEqual(stored.hook_profile, hookMode === 'off' ? 'standard' : hookMode);
});
}
}
}
});
test('interactive named choices install and persist the selected configuration', () => {
if (process.platform === 'win32') return;
withFixture({}, fixture => {
const result = runInteractiveEccSetup(fixture, {
args: [],
answers: ['project', 'strict', 'yes'],
});
assert.ifError(result.error);
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.match(result.stdout, /ECC installed ecc@ecc at project scope/);
assert.match(result.stdout, /Hook preference: strict/);
const state = JSON.parse(fs.readFileSync(fixture.statePath, 'utf8'));
assert.deepStrictEqual(state.plugins, [{
id: 'ecc@ecc',
scope: 'project',
enabled: true,
version: '2.0.0',
}]);
const settings = JSON.parse(
fs.readFileSync(path.join(fixture.configDir, 'settings.json'), 'utf8')
);
const stored = settings.pluginConfigs['ecc@ecc'].options;
assert.strictEqual(stored.hooks_enabled, true);
assert.strictEqual(stored.hook_profile, 'strict');
});
});
test('invalid interactive choices explain the problem and allow a retry', () => {
if (process.platform === 'win32') return;
withFixture({}, fixture => {
const result = runInteractiveEccSetup(fixture, {
args: ['--dry-run'],
answers: ['1.5', 'not-a-scope', '2', '2junk', '9', '2'],
});
assert.ifError(result.error);
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.match(result.stdout, /Please choose 1, 2, or 3/);
assert.match(result.stdout, /Please choose 1, 2, 3, or 4/);
assert.match(result.stdout, /ECC would-install ecc@ecc at project scope/);
assert.match(result.stdout, /Hook preference: minimal/);
assert.strictEqual(hasMutation(fixture), false);
});
});
test('interactive cancellation after non-default choices performs no mutation', () => {
if (process.platform === 'win32') return;
withFixture({}, fixture => {
const result = runInteractiveEccSetup(fixture, {
args: [],
answers: ['2', '2', 'n'],
});
assert.ifError(result.error);
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.match(result.stdout, /ECC cancelled ecc@ecc at project scope/);
assertNoSetupSpinner(`${result.stdout}${result.stderr}`);
assert.strictEqual(hasMutation(fixture), false);
assert.ok(!fs.existsSync(path.join(fixture.configDir, 'settings.json')));
});
});
test('closing interactive input cancels cleanly without mutation', () => {
if (process.platform === 'win32') return;
withFixture({}, fixture => {
const result = runInteractiveEccSetup(fixture, {
args: [],
answers: ['2', '\u0004'],
});
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.ifError(result.error);
assert.match(result.stdout, /cancelled/i);
assert.match(result.stdout, /no changes/i);
assert.strictEqual(hasMutation(fixture), false);
assert.ok(!fs.existsSync(path.join(fixture.configDir, 'settings.json')));
});
});
test('interactive mode flag still prompts for missing scope and hook choices', () => {
if (process.platform === 'win32') return;
withFixture({}, fixture => {
const result = runInteractiveEccSetup(fixture, {
args: ['--mode', 'claude-plugin', '--dry-run'],
answers: ['3', '4'],
});
assert.ifError(result.error);
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.match(result.stdout, /Where should Claude enable ecc@ecc\?/);
assert.match(result.stdout, /How should ECC hooks run\?/);
assert.match(result.stdout, /ECC would-install ecc@ecc at local scope/);
assert.match(result.stdout, /Hook preference: strict/);
});
});
test('interactive defaults preserve an existing install scope and hook preference', () => {
if (process.platform === 'win32') return;
withFixture({
plugins: [{ id: 'ecc@ecc', scope: 'local', enabled: true, version: '1.9.0' }],
marketplaces: [{
name: 'ecc',
source: 'github',
repo: 'affaan-m/ECC',
scope: 'local',
}],
}, fixture => {
fs.writeFileSync(path.join(fixture.configDir, 'settings.json'), JSON.stringify({
pluginConfigs: {
'ecc@ecc': {
options: { hooks_enabled: true, hook_profile: 'minimal' },
},
},
}));
const result = runInteractiveEccSetup(fixture, {
args: ['--dry-run'],
answers: ['', ''],
});
assert.ifError(result.error);
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.match(result.stdout, /Choose \[3\]:/);
assert.match(result.stdout, /Choose \[2\]:/);
assert.match(result.stdout, /ECC would-update ecc@ecc at local scope/);
assert.match(result.stdout, /Hook preference: minimal/);
assert.strictEqual(hasMutation(fixture), false);
});
});
test('partial migration requires an explicit destination and preserves stored hook defaults', () => {
if (process.platform === 'win32') return;
withFixture({
plugins: [
{ id: 'ecc@ecc', scope: 'user', enabled: true, version: '1.9.0' },
{ id: 'ecc@ecc', scope: 'project', enabled: true, version: '2.0.0' },
],
marketplaces: [{
name: 'ecc',
source: 'github',
repo: 'affaan-m/ECC',
scope: 'user',
}],
}, fixture => {
fs.writeFileSync(path.join(fixture.configDir, 'settings.json'), JSON.stringify({
pluginConfigs: {
'ecc@ecc': {
options: { hooks_enabled: true, hook_profile: 'minimal' },
},
},
}));
const result = runInteractiveEccSetup(fixture, {
args: ['--dry-run'],
answers: ['', 'project', ''],
});
assert.ifError(result.error);
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.match(result.stdout, /Choose: /);
assert.match(result.stdout, /Please choose 1, 2, or 3/);
assert.match(result.stdout, /Choose \[2\]:/);
assert.match(result.stdout, /ECC would-resume ecc@ecc at project scope/);
assert.match(result.stdout, /Previous scope: user/);
assert.match(result.stdout, /Hook preference: minimal/);
assert.strictEqual(hasMutation(fixture), false);
});
});
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
+119
View File
@@ -0,0 +1,119 @@
'use strict';
const assert = require('assert');
const path = require('path');
const { spawnSync } = require('child_process');
const { version } = require('../../package.json');
const repoRoot = path.resolve(__dirname, '..', '..');
const eccScript = path.join(repoRoot, 'scripts', 'ecc.js');
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(`${name}`);
passed += 1;
} catch (error) {
console.log(`${name}`);
console.log(` Error: ${error.message}`);
failed += 1;
}
}
function runEcc(args, env = {}) {
return spawnSync(process.execPath, [eccScript, ...args], {
cwd: repoRoot,
encoding: 'utf8',
env: { ...process.env, NO_COLOR: '1', ...env },
});
}
function containsTerminalControlBytes(value) {
return Array.from(value).some(character => {
const codePoint = character.codePointAt(0);
return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
});
}
console.log('\n=== ECC welcome command tests ===\n');
test('ecc welcome renders the install artwork for captured agent output', () => {
const result = runEcc(['welcome']);
assert.strictEqual(result.status, 0, result.stderr);
assert.match(result.stdout, /Welcome to ECC!/);
assert.ok(result.stdout.includes(`v${version}`));
assert.match(result.stdout, /GitHub:\s+https:\/\/github\.com\/affaan-m\/ECC/);
assert.match(result.stdout, /Discord:\s+https:\/\/discord\.gg\/36yGMHGFbR/);
assert.strictEqual(result.stderr, '');
});
test('ecc welcome disables ANSI color when stdout is redirected', () => {
const env = { ...process.env, TERM: 'xterm-256color' };
delete env.NO_COLOR;
const result = spawnSync(process.execPath, [eccScript, 'welcome'], {
cwd: repoRoot,
encoding: 'utf8',
env,
});
assert.strictEqual(result.status, 0, result.stderr);
assert.strictEqual(result.stdout.includes('\u001b['), false);
});
test('ecc welcome supports explicit update and configured outcomes', () => {
const cases = [
['updated', /ECC is updated/],
['configured', /ECC is configured/],
['migrated', /ECC is configured/],
['resumed', /ECC is configured/],
['already-migrated', /ECC is configured/],
];
for (const [action, expected] of cases) {
const result = runEcc(['welcome', '--action', action]);
assert.strictEqual(result.status, 0, result.stderr);
assert.match(result.stdout, expected);
}
});
test('ecc welcome renders a provider-verified installed version', () => {
const result = runEcc(['welcome', '--version', '2.1.0']);
assert.strictEqual(result.status, 0, result.stderr);
assert.match(result.stdout, /v2\.1\.0/);
});
test('ecc welcome rejects unsafe version text', () => {
const result = runEcc(['welcome', '--version', '2.1.0\u001b[31m']);
assert.strictEqual(result.status, 1);
assert.match(result.stderr, /Invalid --version value/);
assert.strictEqual(containsTerminalControlBytes(result.stderr.trimEnd()), false);
assert.strictEqual(result.stdout, '');
});
test('ecc welcome keeps parser error output free of terminal control bytes', () => {
const actionResult = runEcc(['welcome', '--action', 'broken\u001b[31m']);
const argumentResult = runEcc(['welcome', '--bad\u001b[31m']);
for (const result of [actionResult, argumentResult]) {
assert.strictEqual(result.status, 1);
assert.strictEqual(containsTerminalControlBytes(result.stderr.trimEnd()), false);
assert.strictEqual(result.stdout, '');
}
});
test('ecc welcome rejects unknown actions before rendering', () => {
const result = runEcc(['welcome', '--action', 'broken']);
assert.strictEqual(result.status, 1);
assert.match(result.stderr, /Invalid --action value/);
assert.strictEqual(result.stdout, '');
});
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}\n`);
if (failed > 0) process.exit(1);
+1
View File
@@ -595,6 +595,7 @@ __metadata:
ecc-install: scripts/install-apply.js
ecc-memory-mcp: scripts/memory-mcp.mjs
ecc-plan-canvas: scripts/plan-canvas.js
ecc-universal: scripts/ecc.js
languageName: unknown
linkType: soft