diff --git a/.grok-plugin/README.md b/.grok-plugin/README.md new file mode 100644 index 000000000..f4e1b27a8 --- /dev/null +++ b/.grok-plugin/README.md @@ -0,0 +1,65 @@ +# .grok-plugin — Grok Build Native Plugin for ECC + +This directory is the **Grok Build plugin and marketplace manifest** for ECC. +Component files stay at the repository root (`skills/`, `commands/`, `agents/`, +`hooks/hooks.json`, `.mcp.json`). `.grok-plugin/` only holds Grok's index. + +Grok also reads `.claude-plugin/plugin.json`, but Claude's marketplace +`"source": "./"` is rejected by Grok as an empty path. Keep a Grok-specific +catalog here. Do not point Grok at `plugins/ecc/`: Codex already showed that a +thin subdirectory copy drops parent-relative runtime files. + +## Install + +Published repo (marketplace catalog, then install + enable). Grok plugins stay +off until enabled, and hooks/MCP stay inactive until trusted: + +```bash +grok plugin marketplace add affaan-m/ECC +grok plugin install ecc --trust +grok plugin enable ecc +grok plugin validate . +grok inspect +``` + +Local checkout, including unpublished changes: + +```bash +grok plugin install /absolute/path/to/ECC --trust +grok plugin enable ecc +grok plugin validate /absolute/path/to/ECC +grok inspect +``` + +Do not also run `./install.sh --target claude` (or a full manual Claude copy) +into the same Grok session. Pick one install path per harness. + +## Hooks + +`--trust` is required for `hooks/hooks.json`. Grok sets `GROK_PLUGIN_ROOT` and +the `CLAUDE_PLUGIN_ROOT` alias, so ECC's existing hook bootstrap can resolve +the plugin root. + +Grok does not honor Claude `userConfig`. Hook enablement and profile stay on +environment variables: + +```bash +export ECC_HOOKS_ENABLED=true +export ECC_HOOK_PROFILE=standard # minimal | standard | strict +``` + +## MCP + +Claude plugin installs opt out of root `.mcp.json` with `"mcpServers": {}`. +Grok auto-discovers that file. A trusted ECC install therefore attaches the +default `chrome-devtools` server from `.mcp.json`. Additional connectors stay +opt-in via `mcp-configs/mcp-servers.json`. + +## Known limits + +- 286 skills are advertised into Grok's skill catalog. Use a smaller working + set when context footprint matters. +- Agent files load, but ECC agent `tools:` lists use Claude names (`Read`, + `Bash`). Grok hook matchers map those names; agent frontmatter does not. +- Grok has no plugin `rules/` component. Put always-on constraints in + `AGENTS.md` or Grok project rules. diff --git a/.grok-plugin/marketplace.json b/.grok-plugin/marketplace.json new file mode 100644 index 000000000..e73af98c4 --- /dev/null +++ b/.grok-plugin/marketplace.json @@ -0,0 +1,35 @@ +{ + "name": "ecc", + "description": "Harness-native ECC skills, hooks, rules, MCP conventions, and operator workflows", + "owner": { + "name": "Affaan Mustafa", + "email": "me@affaanmustafa.com" + }, + "plugins": [ + { + "name": "ecc", + "description": "Harness-native ECC operator layer - 68 agents, 286 skills, 94 legacy command shims, reusable hooks, rules, selective install profiles, and production-ready workflows for Grok Build, Claude Code, Codex, OpenCode, Cursor, and related agent harnesses", + "version": "2.2.0", + "author": { + "name": "Affaan Mustafa", + "email": "me@affaanmustafa.com" + }, + "homepage": "https://ecc.tools", + "repository": "https://github.com/affaan-m/ECC", + "license": "MIT", + "keywords": [ + "ecc", + "agents", + "skills", + "hooks", + "tdd", + "code-review" + ], + "category": "development", + "source": { + "source": "url", + "url": "https://github.com/affaan-m/ECC.git" + } + } + ] +} diff --git a/.grok-plugin/plugin.json b/.grok-plugin/plugin.json new file mode 100644 index 000000000..6d4e34926 --- /dev/null +++ b/.grok-plugin/plugin.json @@ -0,0 +1,23 @@ +{ + "name": "ecc", + "version": "2.2.0", + "description": "Harness-native ECC plugin for engineering teams - 68 agents, 286 skills, 94 legacy command shims, reusable hooks, rules, MCP conventions, and operator workflows for Grok Build plus adjacent agent harnesses", + "author": { + "name": "Affaan Mustafa", + "url": "https://x.com/affaanmustafa" + }, + "homepage": "https://ecc.tools", + "repository": "https://github.com/affaan-m/ECC", + "license": "MIT", + "keywords": [ + "ecc", + "agents", + "skills", + "hooks", + "tdd", + "code-review", + "security", + "workflow" + ], + "logo": "assets/ecc-icon.svg" +} diff --git a/README.zh-CN.md b/README.zh-CN.md index 3ac5e0904..e7a1f59a8 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -147,6 +147,17 @@ command -v ecc-memory-mcp > 安装名称说明:较早的帖子里可能还会出现较长的旧标识符。Anthropic 的 marketplace/plugin 安装是按规范化插件标识符寻址的,因此 ECC 现在统一为 `ecc@ecc`,让工具名和 slash command 命名空间保持简短。 +Grok Build 走原生插件,而不是 `./install.sh --target grok`。Claude 的 `"source": "./"` 会被 Grok 当成空路径丢掉,所以要用 `.grok-plugin/` 这份目录。插件默认关闭,hooks/MCP 还要 `--trust`: + +```bash +grok plugin marketplace add affaan-m/ECC +grok plugin install ecc --trust +grok plugin enable ecc +grok inspect +``` + +Grok 不读 Claude 的 `userConfig`,钩子档位用 `ECC_HOOK_PROFILE`。信任后会挂上仓库根 `.mcp.json` 里的 `chrome-devtools`。细节见 `.grok-plugin/README.md`。 + ### 第二步:仅在需要时安装规则 > WARNING: **重要提示:** Claude Code 插件无法自动分发 `rules`。 diff --git a/commands/auto-update.md b/commands/auto-update.md index 54524f551..5c077fca8 100644 --- a/commands/auto-update.md +++ b/commands/auto-update.md @@ -11,7 +11,7 @@ Update ECC from its upstream repo and regenerate the current context's managed i ```bash # Preview the update without mutating anything -ECC_ROOT="$(node -e "var 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=(process.env.CLAUDE_CONFIG_DIR||'').trim()||p.join(o.homedir(),'.claude');if(d==='~')d=o.homedir();else if(d.charAt(0)==='~'&&(d.charAt(1)==='/'||d.charCodeAt(1)===92))d=p.join(o.homedir(),d.slice(2));d=p.resolve(d);function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot({probe:p.join('scripts','auto-update.js')})}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 pi) | Agent chains, approval prompts, and persistent todos require companion Pi packages and are not part of this adapter (individual agent definitions are portable via scripts/agent-convert.js); Pi core has no MCP surface, though ECC MCP configs load verbatim through the community pi-mcp-adapter package, which ECC neither installs nor depends on | `pi install git:github.com/affaan-m/ECC`; `pi install /path/to/ECC` from a local checkout | `node tests/pi/pi-package-manifest.test.js`; `node tests/pi/pi-extension-adapter.test.js`; `node tests/pi/agent-convert.test.js`; `npm run harness:adapters -- --check` | Pi extensions execute with full user permissions, and hooks run without a shell and resolve from the installed package rather than the user project; Keep canonical skills and commands as the single source of truth, and never generate copies under .pi/ | | Cursor | Adapter-backed | Cursor rules; project-local skills; hook adapter; shared scripts | Cursor hook events and rule loading differ from Claude Code | `./install.sh --profile minimal --target cursor` | `node tests/lib/install-targets.test.js`; `npm run harness:audit -- --format json` | Cursor adapters must preserve existing project rules and avoid silent overwrite. | diff --git a/docs/ja-JP/commands/auto-update.md b/docs/ja-JP/commands/auto-update.md index cba68c608..c47fb65f5 100644 --- a/docs/ja-JP/commands/auto-update.md +++ b/docs/ja-JP/commands/auto-update.md @@ -11,7 +11,7 @@ ECCをアップストリームリポジトリから更新し、元のインス ```bash # 何も変更せずに更新をプレビュー -ECC_ROOT="$(node -e "var 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({probe:p.join('scripts','auto-update.js')})}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 0) { ```bash node -e " -const aa = require((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{let pending=1;const done=()=>{pending-=1;if(pending===0)process.exit(code);};if(out){pending+=1;process.stdout.write(out,done);}if(err){pending+=1;process.stderr.write(err,done);}process.nextTick(done);};const rel=path.join('scripts','hooks','run-with-flags.js');const root=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.GROK_PLUGIN_ROOT||process.env.CLAUDE_PLUGIN_ROOT||process.env.ECC_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{let pending=1;const done=()=>{pending-=1;if(pending===0)process.exit(code);};if(out){pending+=1;process.stdout.write(out,done);}if(err){pending+=1;process.stderr.write(err,done);}process.nextTick(done);};const rel=path.join('scripts','hooks','run-with-flags.js');const root=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.GROK_PLUGIN_ROOT||process.env.CLAUDE_PLUGIN_ROOT||process.env.ECC_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{let pending=1;const done=()=>{pending-=1;if(pending===0)process.exit(code);};if(out){pending+=1;process.stdout.write(out,done);}if(err){pending+=1;process.stderr.write(err,done);}process.nextTick(done);};const rel=path.join('scripts','hooks','run-with-flags.js');const root=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.GROK_PLUGIN_ROOT||process.env.CLAUDE_PLUGIN_ROOT||process.env.ECC_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{let pending=1;const done=()=>{pending-=1;if(pending===0)process.exit(code);};if(out){pending+=1;process.stdout.write(out,done);}if(err){pending+=1;process.stderr.write(err,done);}process.nextTick(done);};const rel=path.join('scripts','hooks','run-with-flags.js');const root=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.GROK_PLUGIN_ROOT||process.env.CLAUDE_PLUGIN_ROOT||process.env.ECC_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{let pending=1;const done=()=>{pending-=1;if(pending===0)process.exit(code);};if(out){pending+=1;process.stdout.write(out,done);}if(err){pending+=1;process.stderr.write(err,done);}process.nextTick(done);};const rel=path.join('scripts','hooks','run-with-flags.js');const root=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.GROK_PLUGIN_ROOT||process.env.CLAUDE_PLUGIN_ROOT||process.env.ECC_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{let pending=1;const done=()=>{pending-=1;if(pending===0)process.exit(code);};if(out){pending+=1;process.stdout.write(out,done);}if(err){pending+=1;process.stderr.write(err,done);}process.nextTick(done);};const rel=path.join('scripts','hooks','run-with-flags.js');const root=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.GROK_PLUGIN_ROOT||process.env.CLAUDE_PLUGIN_ROOT||process.env.ECC_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{let pending=1;const done=()=>{pending-=1;if(pending===0)process.exit(code);};if(out){pending+=1;process.stdout.write(out,done);}if(err){pending+=1;process.stderr.write(err,done);}process.nextTick(done);};const rel=path.join('scripts','hooks','run-with-flags.js');const root=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.GROK_PLUGIN_ROOT||process.env.CLAUDE_PLUGIN_ROOT||process.env.ECC_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 { parsed.plugins[0].description = description; } ), }, + { + filePath: grokPluginJsonPath, + parseExpectations: content => parseCatalogDescriptionExpectations( + content, + '.grok-plugin/plugin.json description', + parsed => parsed.description + ), + syncContent: (content, catalog) => syncCatalogDescription( + content, + catalog, + '.grok-plugin/plugin.json description', + parsed => parsed.description, + (parsed, description) => { parsed.description = description; } + ), + }, + { + filePath: grokMarketplaceJsonPath, + parseExpectations: content => parseCatalogDescriptionExpectations( + content, + '.grok-plugin/marketplace.json plugin description', + parsed => parsed.plugins?.[0]?.description + ), + syncContent: (content, catalog) => syncCatalogDescription( + content, + catalog, + '.grok-plugin/marketplace.json plugin description', + parsed => parsed.plugins?.[0]?.description, + (parsed, description) => { parsed.plugins[0].description = description; } + ), + }, ]; } @@ -729,6 +763,8 @@ function createDocumentSpecsForRoot(root) { zhDocsAgentsPath: path.join(root, 'docs', 'zh-CN', 'AGENTS.md'), pluginJsonPath: path.join(root, '.claude-plugin', 'plugin.json'), marketplaceJsonPath: path.join(root, '.claude-plugin', 'marketplace.json'), + grokPluginJsonPath: path.join(root, '.grok-plugin', 'plugin.json'), + grokMarketplaceJsonPath: path.join(root, '.grok-plugin', 'marketplace.json'), }); } diff --git a/scripts/hooks/observe-runner.js b/scripts/hooks/observe-runner.js index 5213eb1e2..63f42b2c4 100644 --- a/scripts/hooks/observe-runner.js +++ b/scripts/hooks/observe-runner.js @@ -13,6 +13,9 @@ function getPluginRoot(options = {}) { if (options.pluginRoot && String(options.pluginRoot).trim()) { return String(options.pluginRoot).trim(); } + if (process.env.GROK_PLUGIN_ROOT && process.env.GROK_PLUGIN_ROOT.trim()) { + return process.env.GROK_PLUGIN_ROOT.trim(); + } if (process.env.CLAUDE_PLUGIN_ROOT && process.env.CLAUDE_PLUGIN_ROOT.trim()) { return process.env.CLAUDE_PLUGIN_ROOT.trim(); } @@ -133,6 +136,7 @@ function run(raw, options = {}) { encoding: 'utf8', env: { ...process.env, + GROK_PLUGIN_ROOT: pluginRoot, CLAUDE_PLUGIN_ROOT: pluginRoot, ECC_PLUGIN_ROOT: pluginRoot }, diff --git a/scripts/hooks/plugin-hook-bootstrap.js b/scripts/hooks/plugin-hook-bootstrap.js index 5f05a0aa5..a1a0c0650 100644 --- a/scripts/hooks/plugin-hook-bootstrap.js +++ b/scripts/hooks/plugin-hook-bootstrap.js @@ -173,6 +173,7 @@ function spawnNode(rootDir, relPath, raw, args, options = {}) { ensureAgentDataHomeEnv(); const hookEnv = { ...process.env, + GROK_PLUGIN_ROOT: rootDir, CLAUDE_PLUGIN_ROOT: rootDir, ECC_PLUGIN_ROOT: rootDir, ECC_HOOK_INPUT_MAX_BYTES: String(options.maxStdin), @@ -206,6 +207,7 @@ function spawnShell(rootDir, relPath, raw, args, options = {}) { ensureAgentDataHomeEnv(); const hookEnv = { ...process.env, + GROK_PLUGIN_ROOT: rootDir, CLAUDE_PLUGIN_ROOT: rootDir, ECC_PLUGIN_ROOT: rootDir, ECC_HOOK_INPUT_MAX_BYTES: String(options.maxStdin), @@ -266,7 +268,7 @@ async function main() { contextScanner.push(contextDecoder.end()); const hookContext = contextScanner.context; const rootDir = normalizePluginRootForPlatform( - process.env.QODER_PLUGIN_ROOT || process.env.CLAUDE_PLUGIN_ROOT || process.env.ECC_PLUGIN_ROOT + process.env.GROK_PLUGIN_ROOT || process.env.CLAUDE_PLUGIN_ROOT || process.env.ECC_PLUGIN_ROOT ); if (!mode || !relPath || !rootDir) { diff --git a/scripts/hooks/posttooluse-dispatcher.js b/scripts/hooks/posttooluse-dispatcher.js index e81d1fb37..dc2ecb5e5 100644 --- a/scripts/hooks/posttooluse-dispatcher.js +++ b/scripts/hooks/posttooluse-dispatcher.js @@ -69,7 +69,7 @@ const ASYNC_HOOKS = [ ]; function getPluginRoot(env = process.env) { - return env.CLAUDE_PLUGIN_ROOT || env.ECC_PLUGIN_ROOT || path.resolve(__dirname, '..', '..'); + return env.GROK_PLUGIN_ROOT || env.CLAUDE_PLUGIN_ROOT || env.ECC_PLUGIN_ROOT || path.resolve(__dirname, '..', '..'); } function matchesTool(matcher, toolName) { diff --git a/scripts/hooks/run-with-flags.js b/scripts/hooks/run-with-flags.js index 00c666308..86a975806 100755 --- a/scripts/hooks/run-with-flags.js +++ b/scripts/hooks/run-with-flags.js @@ -142,6 +142,9 @@ function truncatedInputResult(hookId, maxStdin) { } function getPluginRoot() { + if (process.env.GROK_PLUGIN_ROOT && process.env.GROK_PLUGIN_ROOT.trim()) { + return process.env.GROK_PLUGIN_ROOT; + } if (process.env.CLAUDE_PLUGIN_ROOT && process.env.CLAUDE_PLUGIN_ROOT.trim()) { return process.env.CLAUDE_PLUGIN_ROOT; } @@ -319,6 +322,7 @@ async function main() { encoding: 'utf8', env: { ...process.env, + GROK_PLUGIN_ROOT: pluginRoot, CLAUDE_PLUGIN_ROOT: pluginRoot, ECC_PLUGIN_ROOT: pluginRoot, ECC_HOOK_ID: hookId, diff --git a/scripts/lib/harness-adapter-compliance.js b/scripts/lib/harness-adapter-compliance.js index c4178a9f4..a5bbe616f 100644 --- a/scripts/lib/harness-adapter-compliance.js +++ b/scripts/lib/harness-adapter-compliance.js @@ -97,6 +97,44 @@ const ADAPTER_RECORDS = Object.freeze([ 'scripts/lib/install-targets/codex-home.js', ], }, + { + id: 'grok-build', + harness: 'Grok Build', + state: 'Adapter-backed', + supported_assets: [ + 'Grok plugin and marketplace manifests', + 'shared skills', + 'commands', + 'hooks after `--trust`', + 'root `.mcp.json` after trust', + ], + unsupported_surfaces: [ + 'Claude userConfig hook profiles are ignored; use ECC_HOOK_PROFILE', + 'ECC agent tool allowlists use Claude names and are not mapped in Grok agent frontmatter', + 'No plugin rules/ surface; no ecc install --target grok', + ], + install_or_onramp: [ + '`grok plugin marketplace add affaan-m/ECC && grok plugin install ecc --trust && grok plugin enable ecc`', + '`grok plugin install /path/to/ECC --trust && grok plugin enable ecc`', + ], + verification_commands: [ + '`grok plugin validate .`', + '`node tests/plugin-manifest.test.js`', + '`node tests/lib/resolve-ecc-root.test.js`', + ], + risk_notes: [ + 'Grok rejects Claude marketplace source "./"; keep a url catalog in .grok-plugin/marketplace.json', + 'Trusted installs attach chrome-devtools from root .mcp.json; the full skill catalog is large', + ], + last_verified_at: '2026-08-24', + owner: 'ECC maintainers', + source_docs: [ + '.grok-plugin/plugin.json', + '.grok-plugin/marketplace.json', + '.grok-plugin/README.md', + 'scripts/lib/resolve-ecc-root.js', + ], + }, { id: 'opencode', harness: 'OpenCode', diff --git a/scripts/lib/hook-flags.js b/scripts/lib/hook-flags.js index 69d3f25d6..60da9d90d 100644 --- a/scripts/lib/hook-flags.js +++ b/scripts/lib/hook-flags.js @@ -41,7 +41,7 @@ function sanitizeDiagnostic(value) { function readManagedHookConfig(env = process.env) { const pluginRoot = String( - env.CLAUDE_PLUGIN_ROOT || env.ECC_PLUGIN_ROOT || '' + env.GROK_PLUGIN_ROOT || env.CLAUDE_PLUGIN_ROOT || env.ECC_PLUGIN_ROOT || '' ).trim(); const configPath = String(env.ECC_HOOK_CONFIG || '').trim() || (pluginRoot ? path.join(pluginRoot, 'ecc', 'setup.json') : ''); diff --git a/scripts/lib/resolve-ecc-root.js b/scripts/lib/resolve-ecc-root.js index b6f6eaf25..a82806545 100644 --- a/scripts/lib/resolve-ecc-root.js +++ b/scripts/lib/resolve-ecc-root.js @@ -29,20 +29,90 @@ const PLUGIN_ROOT_SEGMENTS = [ // If that skill is ever renamed, move this sentinel with it. const DEFAULT_SCRIPT_PROBE = path.join('scripts', 'lib', 'utils.js'); const DEFAULT_SKILL_PROBE = path.join('skills', 'continuous-learning-v2'); +const PLUGIN_ROOT_ENV_KEYS = ['GROK_PLUGIN_ROOT', 'CLAUDE_PLUGIN_ROOT', 'ECC_PLUGIN_ROOT']; + +function pluginRootFromEnv(env = process.env) { + for (const key of PLUGIN_ROOT_ENV_KEYS) { + const value = env[key]; + if (value && String(value).trim()) { + return String(value).trim(); + } + } + return ''; +} + +function findRootInVendorHome(vendorDir, isRoot) { + if (isRoot(vendorDir)) { + return vendorDir; + } + + for (const segments of PLUGIN_ROOT_SEGMENTS) { + const candidate = path.join(vendorDir, 'plugins', ...segments); + if (isRoot(candidate)) { + return candidate; + } + } + + try { + for (const slug of PLUGIN_CACHE_SLUGS) { + const cacheBase = path.join(vendorDir, 'plugins', 'cache', slug); + const orgDirs = fs.readdirSync(cacheBase, { withFileTypes: true }); + + for (const orgEntry of orgDirs) { + if (!orgEntry.isDirectory()) continue; + const orgPath = path.join(cacheBase, orgEntry.name); + + let versionDirs; + try { + versionDirs = fs.readdirSync(orgPath, { withFileTypes: true }); + } catch { + continue; + } + + for (const verEntry of versionDirs) { + if (!verEntry.isDirectory()) continue; + const candidate = path.join(orgPath, verEntry.name); + if (isRoot(candidate)) { + return candidate; + } + } + } + } + } catch { + // Vendor plugin cache doesn't exist or isn't readable. + } + + try { + const installedBase = path.join(vendorDir, 'installed-plugins'); + const installedDirs = fs.readdirSync(installedBase, { withFileTypes: true }); + for (const entry of installedDirs) { + if (!entry.isDirectory()) continue; + const candidate = path.join(installedBase, entry.name); + if (isRoot(candidate)) { + return candidate; + } + } + } catch { + // Grok (and similar) installed-plugins homes are optional. + } + + return null; +} /** * Resolve the ECC source root directory. * * Tries, in order: - * 1. CLAUDE_PLUGIN_ROOT env var (set by Claude Code for hooks, or by user) - * 2. Standard install location (~/.claude/) — when scripts exist there + * 1. GROK_PLUGIN_ROOT, CLAUDE_PLUGIN_ROOT, or ECC_PLUGIN_ROOT + * 2. Standard install location (~/.claude/) — when a complete root exists there * 3. Known plugin roots under ~/.claude/plugins/ (current + legacy slugs) * 4. Plugin cache auto-detection — scans ~/.claude/plugins/cache/{ecc,everything-claude-code}/ - * 5. Fallback to ~/.claude/ (original behaviour) + * 5. The same layout under ~/.grok/, plus ~/.grok/installed-plugins// + * 6. Fallback to ~/.claude/ (original behaviour) * * @param {object} [options] * @param {string} [options.homeDir] Override home directory (for testing) - * @param {string} [options.envRoot] Override CLAUDE_PLUGIN_ROOT (for testing) + * @param {string} [options.envRoot] Override plugin-root env (for testing) * @param {string} [options.probe] Relative path used to verify a candidate * root contains what the caller needs. When * given, it is honored exactly (script @@ -56,23 +126,15 @@ const DEFAULT_SKILL_PROBE = path.join('skills', 'continuous-learning-v2'); function resolveEccRoot(options = {}) { const envRoot = options.envRoot !== undefined ? options.envRoot - : (process.env.CLAUDE_PLUGIN_ROOT || ''); + : pluginRootFromEnv(process.env); if (envRoot && envRoot.trim()) { return envRoot.trim(); } const homeDir = options.homeDir || os.homedir(); - const configuredDir = options.configDir !== undefined - ? options.configDir - : process.env.CLAUDE_CONFIG_DIR; - const rawDir = typeof configuredDir === 'string' ? configuredDir.trim() : ''; - const expandedDir = rawDir === '~' - ? homeDir - : (rawDir.startsWith('~/') || rawDir.startsWith('~\\')) - ? path.join(homeDir, rawDir.slice(2)) - : rawDir; - const claudeDir = expandedDir ? path.resolve(expandedDir) : path.join(homeDir, '.claude'); + const claudeDir = path.join(homeDir, '.claude'); + const grokDir = path.join(homeDir, '.grok'); // Decide whether a candidate directory is a usable ECC root. An explicit // caller probe is honored exactly (script consumers know the artifact they @@ -85,42 +147,9 @@ function resolveEccRoot(options = {}) { : (dir) => fs.existsSync(path.join(dir, DEFAULT_SCRIPT_PROBE)) && fs.existsSync(path.join(dir, DEFAULT_SKILL_PROBE)); - // Standard install — files are copied directly into ~/.claude/ - if (isRoot(claudeDir)) { - return claudeDir; - } - - // Exact legacy plugin install locations. These preserve backwards - // compatibility without scanning arbitrary plugin trees. - const legacyPluginRoots = PLUGIN_ROOT_SEGMENTS.map((segments) => - path.join(claudeDir, 'plugins', ...segments) - ); - - for (const candidate of legacyPluginRoots) { - if (isRoot(candidate)) { - return candidate; - } - } - - // Missing cache locations are independent; unreadable/corrupt locations must surface. - const directories = (dir) => { - try { return fs.readdirSync(dir, { withFileTypes: true }); } - catch (error) { if (error.code === 'ENOENT') return [];throw error; } - }; - for (const slug of PLUGIN_CACHE_SLUGS) { - const cacheBase = path.join(claudeDir, 'plugins', 'cache', slug); - for (const orgEntry of directories(cacheBase)) { - if (!orgEntry.isDirectory()) continue; - const orgPath = path.join(cacheBase, orgEntry.name); - for (const verEntry of directories(orgPath)) { - if (!verEntry.isDirectory()) continue; - const candidate = path.join(orgPath, verEntry.name); - if (isRoot(candidate)) return candidate; - } - } - } - - return claudeDir; + return findRootInVendorHome(claudeDir, isRoot) + || findRootInVendorHome(grokDir, isRoot) + || claudeDir; } function normalizePluginRootForPlatform(rootDir, platform = process.platform) { @@ -142,22 +171,21 @@ function normalizePluginRootForPlatform(rootDir, platform = process.platform) { * * This minified form contains no spread, no nested array literals, and no * escaped double quotes, so it survives `node -e "..."` quoting on every shell. - * When CLAUDE_PLUGIN_ROOT is set (as Claude Code does for plugin hooks and - * commands) it is used directly. Otherwise the inline probes the same set of - * locations resolveEccRoot() knows about — ~/.claude, the exact plugin roots - * under ~/.claude/plugins/, and the versioned plugin cache — only far enough to - * load the committed resolve-ecc-root module, then delegates the authoritative - * decision to resolveEccRoot(). This keeps discovery behaviour identical to the - * old inline while centralising the real logic in one tested module. + * When GROK_PLUGIN_ROOT, CLAUDE_PLUGIN_ROOT, or ECC_PLUGIN_ROOT is set it is + * used directly. Grok trusted plugin hooks set both GROK_PLUGIN_ROOT and the + * CLAUDE_PLUGIN_ROOT alias. Otherwise the inline probes ~/.claude plugin + * locations only far enough to load this module, then delegates to + * resolveEccRoot() (which also searches ~/.grok). * * Usage in commands: * const _r = ; * const sm = require(_r + '/scripts/lib/session-manager'); */ -const INLINE_RESOLVE = `(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=(process.env.CLAUDE_CONFIG_DIR||'').trim()||p.join(o.homedir(),'.claude');if(d==='~')d=o.homedir();else if(d.charAt(0)==='~'&&(d.charAt(1)==='/'||d.charCodeAt(1)===92))d=p.join(o.homedir(),d.slice(2));d=p.resolve(d);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 { for (const harness of [ 'Claude Code', 'Codex', + 'Grok Build', 'OpenCode', 'Cursor', 'Gemini', diff --git a/tests/hooks/hook-flags.test.js b/tests/hooks/hook-flags.test.js index 9e566edbf..cbf650d6e 100644 --- a/tests/hooks/hook-flags.test.js +++ b/tests/hooks/hook-flags.test.js @@ -91,6 +91,7 @@ function runTests() { ECC_HOOKS_ENABLED: undefined, CLAUDE_PLUGIN_OPTION_HOOKS_ENABLED: undefined, ECC_HOOK_CONFIG: undefined, + GROK_PLUGIN_ROOT: undefined, CLAUDE_PLUGIN_ROOT: undefined, ECC_PLUGIN_ROOT: undefined, }, () => { @@ -98,6 +99,36 @@ function runTests() { }); })) passed++; else failed++; + if (test('GROK_PLUGIN_ROOT locates managed hook config when Claude root is unset', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-hook-flags-grok-')); + 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: undefined, + CLAUDE_PLUGIN_ROOT: undefined, + ECC_PLUGIN_ROOT: undefined, + GROK_PLUGIN_ROOT: root, + }, () => { + 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('Claude plugin options control enabled state and profile', () => { withEnv({ ECC_HOOKS_ENABLED: undefined, diff --git a/tests/lib/resolve-ecc-root.test.js b/tests/lib/resolve-ecc-root.test.js index f532eba98..ebf0d411c 100644 --- a/tests/lib/resolve-ecc-root.test.js +++ b/tests/lib/resolve-ecc-root.test.js @@ -2,11 +2,12 @@ * Tests for scripts/lib/resolve-ecc-root.js * * Covers the ECC root resolution fallback chain: - * 1. CLAUDE_PLUGIN_ROOT env var + * 1. GROK_PLUGIN_ROOT / CLAUDE_PLUGIN_ROOT / ECC_PLUGIN_ROOT env vars * 2. Standard install (~/.claude/) * 3. Exact legacy plugin roots under ~/.claude/plugins/ * 4. Plugin cache auto-detection - * 5. Fallback to ~/.claude/ + * 5. Grok plugin homes (~/.grok/plugins/, ~/.grok/installed-plugins/) + * 6. Fallback to ~/.claude/ */ const assert = require('assert'); @@ -19,8 +20,8 @@ const CURRENT_PACKAGE_VERSION = JSON.parse( const { resolveEccRoot, - normalizePluginRootForPlatform, - INLINE_RESOLVE + INLINE_RESOLVE, + pluginRootFromEnv, } = require('../../scripts/lib/resolve-ecc-root'); // Sentinel ECC skill that resolveEccRoot() requires (alongside the script tree) @@ -61,6 +62,24 @@ function setupLegacyPluginInstall(homeDir, segments) { fs.mkdirSync(path.join(legacyDir, ECC_SKILL_SENTINEL), { recursive: true }); return legacyDir; } +function setupVendorPluginInstall(homeDir, vendor, segments) { + const pluginDir = path.join(homeDir, vendor, 'plugins', ...segments); + const scriptDir = path.join(pluginDir, 'scripts', 'lib'); + fs.mkdirSync(scriptDir, { recursive: true }); + fs.writeFileSync(path.join(scriptDir, 'utils.js'), '// stub'); + fs.mkdirSync(path.join(pluginDir, ECC_SKILL_SENTINEL), { recursive: true }); + return pluginDir; +} + +function setupGrokInstalledPlugin(homeDir, dirName) { + const pluginDir = path.join(homeDir, '.grok', 'installed-plugins', dirName); + const scriptDir = path.join(pluginDir, 'scripts', 'lib'); + fs.mkdirSync(scriptDir, { recursive: true }); + fs.writeFileSync(path.join(scriptDir, 'utils.js'), '// stub'); + fs.mkdirSync(path.join(pluginDir, ECC_SKILL_SENTINEL), { recursive: true }); + return pluginDir; +} + function setupPluginCache(homeDir, pluginSlug, orgName, version) { const cacheDir = path.join( homeDir, '.claude', 'plugins', 'cache', @@ -518,6 +537,119 @@ module.exports = { resolveEccRoot() { assert.strictEqual(process.env.HOME, ${JSO } })) passed++; else failed++; + if (test('pluginRootFromEnv prefers GROK_PLUGIN_ROOT then CLAUDE then ECC', () => { + assert.strictEqual( + pluginRootFromEnv({ + GROK_PLUGIN_ROOT: '/grok/root', + CLAUDE_PLUGIN_ROOT: '/claude/root', + ECC_PLUGIN_ROOT: '/ecc/root', + }), + '/grok/root' + ); + assert.strictEqual( + pluginRootFromEnv({ + CLAUDE_PLUGIN_ROOT: '/claude/root', + ECC_PLUGIN_ROOT: '/ecc/root', + }), + '/claude/root' + ); + assert.strictEqual(pluginRootFromEnv({ ECC_PLUGIN_ROOT: '/ecc/root' }), '/ecc/root'); + assert.strictEqual(pluginRootFromEnv({ GROK_PLUGIN_ROOT: ' /trimmed/grok ' }), '/trimmed/grok'); + assert.strictEqual(pluginRootFromEnv({ GROK_PLUGIN_ROOT: ' ' }), ''); + assert.strictEqual(pluginRootFromEnv({}), ''); + })) passed++; else failed++; + + if (test('reads GROK_PLUGIN_ROOT from process.env when envRoot is omitted', () => { + const previous = process.env.GROK_PLUGIN_ROOT; + const previousClaude = process.env.CLAUDE_PLUGIN_ROOT; + const previousEcc = process.env.ECC_PLUGIN_ROOT; + const homeDir = createTempDir(); + try { + process.env.GROK_PLUGIN_ROOT = '/from/grok/env'; + delete process.env.CLAUDE_PLUGIN_ROOT; + delete process.env.ECC_PLUGIN_ROOT; + assert.strictEqual(resolveEccRoot({ homeDir }), '/from/grok/env'); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + if (previous === undefined) delete process.env.GROK_PLUGIN_ROOT; + else process.env.GROK_PLUGIN_ROOT = previous; + if (previousClaude === undefined) delete process.env.CLAUDE_PLUGIN_ROOT; + else process.env.CLAUDE_PLUGIN_ROOT = previousClaude; + if (previousEcc === undefined) delete process.env.ECC_PLUGIN_ROOT; + else process.env.ECC_PLUGIN_ROOT = previousEcc; + } + })) passed++; else failed++; + + if (test('finds Grok plugin install at ~/.grok/plugins/ecc', () => { + const homeDir = createTempDir(); + try { + const expected = setupVendorPluginInstall(homeDir, '.grok', ['ecc']); + const result = resolveEccRoot({ envRoot: '', homeDir }); + assert.strictEqual(result, expected); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('finds Grok plugin install at ~/.grok/plugins/ecc@ecc', () => { + const homeDir = createTempDir(); + try { + const expected = setupVendorPluginInstall(homeDir, '.grok', ['ecc@ecc']); + const result = resolveEccRoot({ envRoot: '', homeDir }); + assert.strictEqual(result, expected); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('finds Grok marketplace plugin install at ~/.grok/plugins/marketplaces/ecc', () => { + const homeDir = createTempDir(); + try { + const expected = setupVendorPluginInstall(homeDir, '.grok', ['marketplaces', 'ecc']); + const result = resolveEccRoot({ envRoot: '', homeDir }); + assert.strictEqual(result, expected); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('finds Grok installed-plugins hash directory when that is the only complete root', () => { + const homeDir = createTempDir(); + try { + const expected = setupGrokInstalledPlugin(homeDir, 'ecc-ab12cd34'); + const result = resolveEccRoot({ envRoot: '', homeDir }); + assert.strictEqual(result, expected); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('prefers a complete Claude root over a complete Grok root when env is unset', () => { + const homeDir = createTempDir(); + try { + const claudeDir = setupStandardInstall(homeDir); + setupGrokInstalledPlugin(homeDir, 'ecc-ab12cd34'); + const result = resolveEccRoot({ envRoot: '', homeDir }); + assert.strictEqual(result, claudeDir); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('INLINE_RESOLVE returns GROK_PLUGIN_ROOT when set', () => { + const { execFileSync } = require('child_process'); + const env = { ...process.env, GROK_PLUGIN_ROOT: '/inline/grok/root' }; + delete env.CLAUDE_PLUGIN_ROOT; + delete env.ECC_PLUGIN_ROOT; + const result = execFileSync('node', [ + '-e', `console.log(${INLINE_RESOLVE})`, + ], { + env, + encoding: 'utf8', + }).trim(); + assert.strictEqual(result, '/inline/grok/root'); + })) passed++; else failed++; + if (test('INLINE_RESOLVE falls back to ~/.claude/ when nothing found', () => { const homeDir = createTempDir(); try { diff --git a/tests/plugin-manifest.test.js b/tests/plugin-manifest.test.js index be7a39194..6484a8995 100644 --- a/tests/plugin-manifest.test.js +++ b/tests/plugin-manifest.test.js @@ -2,8 +2,10 @@ * Tests for plugin manifests: * - .claude-plugin/plugin.json (Claude Code plugin) * - .codex-plugin/plugin.json (Codex native plugin) + * - .grok-plugin/plugin.json (Grok Build plugin) * - .mcp.json (MCP server config at plugin root) * - .agents/plugins/marketplace.json (Codex marketplace discovery) + * - .grok-plugin/marketplace.json (Grok Build marketplace) * * Enforces rules from: * - .claude-plugin/PLUGIN_SCHEMA_NOTES.md (Claude Code validator rules) @@ -689,6 +691,108 @@ test('user-facing docs do not use the legacy non-URL marketplace add form', () = assert.deepStrictEqual(offenders, [], `Legacy non-URL marketplace add form must not appear in user-facing docs: ${offenders.join(', ')}`); }); +// ── Grok Build plugin manifest ──────────────────────────────────────────────── +console.log('\n=== .grok-plugin/plugin.json ===\n'); + +const grokPluginPath = path.join(repoRoot, '.grok-plugin', 'plugin.json'); +const grokMarketplacePath = path.join(repoRoot, '.grok-plugin', 'marketplace.json'); +const grokPluginReadmePath = path.join(repoRoot, '.grok-plugin', 'README.md'); + +test('grok plugin.json exists', () => { + assert.ok(fs.existsSync(grokPluginPath), 'Expected .grok-plugin/plugin.json to exist'); +}); + +test('grok marketplace.json exists', () => { + assert.ok(fs.existsSync(grokMarketplacePath), 'Expected .grok-plugin/marketplace.json to exist'); +}); + +test('grok plugin README exists', () => { + assert.ok(fs.existsSync(grokPluginReadmePath), 'Expected .grok-plugin/README.md to exist'); +}); + +const grokPlugin = fs.existsSync(grokPluginPath) + ? loadJsonObject(grokPluginPath, '.grok-plugin/plugin.json') + : {}; +const grokMarketplace = fs.existsSync(grokMarketplacePath) + ? loadJsonObject(grokMarketplacePath, '.grok-plugin/marketplace.json') + : {}; + +test('grok plugin.json uses short plugin slug and matching version', () => { + assert.strictEqual(grokPlugin.name, 'ecc'); + assert.strictEqual(grokPlugin.version, expectedVersion); +}); + +test('grok plugin.json keeps Claude-incompatible fields out of the Grok manifest', () => { + assert.ok(!('userConfig' in grokPlugin), 'Grok does not honor Claude userConfig; hook profile stays on ECC_HOOK_PROFILE'); + assert.ok(!('mcpServers' in grokPlugin), 'Do not copy Claude mcpServers:{} into Grok; Grok auto-discovers root .mcp.json'); + assert.ok(!('agents' in grokPlugin), 'Grok discovers agents/ by convention'); + assert.ok(!('hooks' in grokPlugin), 'Grok discovers hooks/hooks.json by convention'); +}); + +test('grok plugin.json description includes catalog counts', () => { + assert.ok( + typeof grokPlugin.description === 'string' + && /\d+\s+agents,\s+\d+\s+skills,\s+\d+\s+legacy command shims/i.test(grokPlugin.description), + 'Grok plugin description must include catalog counts so scripts/ci/catalog.js can keep them in sync' + ); +}); + +test('grok plugin.json points at canonical ECC identity', () => { + assert.strictEqual(grokPlugin.homepage, 'https://ecc.tools'); + assert.strictEqual(grokPlugin.repository, 'https://github.com/affaan-m/ECC'); + assert.strictEqual(grokPlugin.license, 'MIT'); + assert.ok(grokPlugin.author && grokPlugin.author.name, 'Expected author.name'); + assert.strictEqual(grokPlugin.logo, 'assets/ecc-icon.svg'); + assert.ok(fs.existsSync(path.join(repoRoot, grokPlugin.logo)), 'Expected Grok plugin logo to exist'); +}); + +test('grok marketplace.json names the ecc catalog and plugin', () => { + assert.strictEqual(grokMarketplace.name, 'ecc'); + assert.ok(typeof grokMarketplace.description === 'string' && grokMarketplace.description.trim(), 'Grok marketplace.json may have a top-level description'); + assert.ok(Array.isArray(grokMarketplace.plugins) && grokMarketplace.plugins.length > 0, 'Expected plugins array'); + assert.strictEqual(grokMarketplace.plugins[0].name, 'ecc'); + assert.strictEqual(grokMarketplace.plugins[0].version, expectedVersion); +}); + +test('grok marketplace plugin source is a Git URL of the whole repo, not ./', () => { + const plugin = grokMarketplace.plugins[0]; + assert.ok(plugin.source && typeof plugin.source === 'object', 'Grok rejects Claude\'s string source "./" as an empty marketplace path'); + assert.notStrictEqual(plugin.source, './'); + assert.strictEqual(plugin.source.source, 'url'); + assert.strictEqual(plugin.source.url, 'https://github.com/affaan-m/ECC.git'); + assert.ok(!plugin.source.sha, 'In-repo catalog must not pin a self-SHA; pin SHAs only in an external index'); + assert.ok(!plugin.source.path, 'Do not point Grok at plugins/ecc; Codex already proved thin subdir copies drop runtime files'); +}); + +test('grok marketplace plugin description includes catalog counts', () => { + assert.ok( + typeof grokMarketplace.plugins[0].description === 'string' + && /\d+\s+agents,\s+\d+\s+skills,\s+\d+\s+legacy command shims/i.test(grokMarketplace.plugins[0].description), + 'Grok marketplace plugin description must include catalog counts' + ); +}); + +test('package.json files includes the Grok plugin directory', () => { + assert.ok(Array.isArray(rootPackage.files), 'Expected package.json files array'); + assert.ok( + rootPackage.files.includes('.grok-plugin/'), + 'npm pack must include .grok-plugin/ so published tarballs remain Grok-installable' + ); +}); + +test('.grok-plugin README documents install, trust, enable, MCP, and hook profile', () => { + const readme = fs.readFileSync(grokPluginReadmePath, 'utf8'); + assert.ok(readme.includes('grok plugin marketplace add affaan-m/ECC'), 'Expected marketplace add for the canonical ECC repo'); + assert.ok(readme.includes('grok plugin install ecc --trust'), 'Expected trusted marketplace install'); + assert.ok(readme.includes('grok plugin enable ecc'), 'Grok plugins stay off until enabled'); + assert.ok(readme.includes('grok plugin install'), 'Expected a local-checkout install path'); + assert.ok(readme.includes('ECC_HOOK_PROFILE'), 'Grok has no Claude userConfig; document ECC_HOOK_PROFILE'); + assert.ok(readme.includes('.mcp.json'), 'Document that trusted Grok installs attach root .mcp.json'); + assert.ok(readme.includes('chrome-devtools'), 'Name the default MCP server Grok will attach'); + assert.ok(readme.includes('grok plugin validate'), 'Expected a machine-checkable validation command'); + assert.ok(readme.includes('grok inspect'), 'Expected inspect as the post-install verification command'); +}); + 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'); diff --git a/tests/scripts/build-opencode.test.js b/tests/scripts/build-opencode.test.js index 66359d4cc..39ebfda41 100644 --- a/tests/scripts/build-opencode.test.js +++ b/tests/scripts/build-opencode.test.js @@ -324,6 +324,14 @@ function main() { packagedPaths.has(".codex-plugin/plugin.json"), "npm pack should include .codex-plugin/plugin.json" ) + assert.ok( + packagedPaths.has(".grok-plugin/plugin.json"), + "npm pack should include .grok-plugin/plugin.json" + ) + assert.ok( + packagedPaths.has(".grok-plugin/marketplace.json"), + "npm pack should include .grok-plugin/marketplace.json" + ) assert.ok( packagedPaths.has(".agents/plugins/marketplace.json"), "npm pack should include .agents/plugins/marketplace.json" diff --git a/tests/scripts/npm-publish-surface.test.js b/tests/scripts/npm-publish-surface.test.js index 84afcb203..956c86566 100644 --- a/tests/scripts/npm-publish-surface.test.js +++ b/tests/scripts/npm-publish-surface.test.js @@ -294,7 +294,8 @@ function main() { ".claude-plugin/plugin.json", ".github/PULL_REQUEST_TEMPLATE.md", ".codex-plugin/plugin.json", - ".qoder-plugin/plugin.json", + ".grok-plugin/plugin.json", + ".grok-plugin/marketplace.json", ".agents/skills/unified-memory/SKILL.md", ".agents/skills/unified-memory/agents/openai.yaml", ".cursor/skills/unified-memory/SKILL.md",