fix(install): strip ecc: plugin namespace from manual Claude installs

The ecc@ecc plugin namespaces agents, commands and skills as `ecc:<name>`.
A manual install (install-apply --target claude|claude-project) places the
same files at bare names under ~/.claude, so plugin-prefixed references in
copied markdown point at agents and commands that do not exist in that
layout (e.g. subagent_type "ecc:planner", frontmatter agent: ecc:...).

- Add plugin-namespace.js with stripPluginNamespace(), which removes the
  `ecc:` prefix before an identifier and leaves `ecc@ecc`, URLs and words
  ending in "ecc" alone.
- Apply it to markdown copy operations when the target is claude or
  claude-project. Non-markdown files and other targets are unchanged.
- Reword the agents rule so it describes both install modes instead of
  stating that agents only ship with the plugin.
- Cover the transform with tests/lib/install-plugin-namespace.test.js.
This commit is contained in:
serhattayaz-ai
2026-10-08 16:10:35 +03:00
parent ef648e0189
commit 27d58e589a
4 changed files with 117 additions and 4 deletions
+2 -2
View File
@@ -2,8 +2,8 @@
## Available Agents
ECC agents ship with the `ecc@ecc` plugin, not in `~/.claude/agents/`.
They are invoked through the Agent tool with a plugin-scoped `subagent_type`:
ECC agents come either from the `ecc@ecc` plugin (plugin-scoped names, `ecc:<name>`) or from a manual install into `~/.claude/agents/` (bare names, `<name>`). Use the name that matches the install you have; the manual installer strips the `ecc:` prefix from copied files.
They are invoked through the Agent tool with that `subagent_type`:
```text
Agent(subagent_type: "ecc:planner", prompt: "...")
+10 -2
View File
@@ -51,6 +51,11 @@ const {
prepareExcludedPathsReconciliation,
} = require('./excluded-paths-reconciliation');
const { buildInstallIndex, rewriteRelativeLinks } = require('./link-rewrite');
const { stripPluginNamespace } = require('./plugin-namespace');
// Manual installs place agents, commands and skills at bare names; the
// ecc@ecc plugin namespaces them as `ecc:<name>`.
const MANUAL_CLAUDE_TARGETS = new Set(['claude', 'claude-project']);
const { adaptAntigravityAgent } = require('./antigravity-agent');
function isMarkdownPath(filePath) {
@@ -874,13 +879,16 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
&& operation.sourceRelativePath
&& isMarkdownPath(operation.destinationPath)
);
if (operation.kind === 'copy-file' && (operation.contentTransform || needsLinkRewrite)) {
const stripNamespace = MANUAL_CLAUDE_TARGETS.has(plan.adapter?.target)
&& isMarkdownPath(operation.destinationPath);
if (operation.kind === 'copy-file' && (operation.contentTransform || needsLinkRewrite || stripNamespace)) {
const transformed = transformInstallContent(
operation,
fs.readFileSync(operation.sourcePath, 'utf8')
);
const namespaced = stripNamespace ? stripPluginNamespace(transformed) : transformed;
const installedContent = needsLinkRewrite
? rewriteRelativeLinks(transformed, {
? rewriteRelativeLinks(namespaced, {
sourceRel: operation.sourceRelativePath,
index: linkIndex,
})
+19
View File
@@ -0,0 +1,19 @@
'use strict';
// The ecc@ecc plugin exposes agents, commands and skills under an `ecc:`
// namespace (`ecc:planner`, `/ecc:plan`). A manual install
// (install-apply --target claude|claude-project) places the same files at bare
// names, so plugin-prefixed references in copied markdown must drop the prefix
// or they point at agents and commands that do not exist in that layout.
//
// The leading guard keeps `ecc@ecc` and words that merely end in "ecc" (e.g.
// "decc:") untouched; the lookahead requires a real identifier after the colon.
const PLUGIN_NAMESPACE_PREFIX = /(^|[^A-Za-z0-9_@.-])ecc:(?=[a-z][a-z0-9-]*)/gm;
function stripPluginNamespace(content) {
return String(content).replace(PLUGIN_NAMESPACE_PREFIX, '$1');
}
module.exports = {
stripPluginNamespace,
};
@@ -0,0 +1,86 @@
/**
* Tests for scripts/lib/install/plugin-namespace.js — manual Claude installs
* place agents, commands and skills at bare names, so plugin-prefixed
* `ecc:<name>` references in copied markdown must lose the prefix.
*/
'use strict';
const assert = require('assert');
const { stripPluginNamespace } = require('../../scripts/lib/install/plugin-namespace');
function test(name, fn) {
try {
fn();
console.log(` PASS ${name}`);
return true;
} catch (error) {
console.log(` FAIL ${name}`);
console.log(` Error: ${error.message}`);
console.error(error.stack || error);
return false;
}
}
function runTests() {
console.log('Running install plugin-namespace tests...\n');
let passed = 0;
let failed = 0;
const record = ok => (ok ? passed++ : failed++);
record(test('strips the prefix from agent names in code spans, bold and tables', () => {
const input = [
'| ecc:planner | Planning |',
'1. Use **ecc:code-reviewer** agent',
'Use `ecc:tdd-guide` here',
].join('\n');
assert.strictEqual(stripPluginNamespace(input), [
'| planner | Planning |',
'1. Use **code-reviewer** agent',
'Use `tdd-guide` here',
].join('\n'));
}));
record(test('strips the prefix from agent frontmatter and tool-call arguments', () => {
assert.strictEqual(
stripPluginNamespace('agent: ecc:security-reviewer'),
'agent: security-reviewer'
);
assert.strictEqual(
stripPluginNamespace('Agent(subagent_type: "ecc:planner", prompt: "...")'),
'Agent(subagent_type: "planner", prompt: "...")'
);
}));
record(test('strips the prefix from slash commands', () => {
assert.strictEqual(stripPluginNamespace('see `/ecc:ecc-guide`'), 'see `/ecc-guide`');
assert.strictEqual(stripPluginNamespace('/ecc:plan "Add auth"'), '/plan "Add auth"');
}));
record(test('leaves the plugin identifier ecc@ecc untouched', () => {
const input = '`/plugin install ecc@ecc` and `ecc@ecc`';
assert.strictEqual(stripPluginNamespace(input), input);
}));
record(test('leaves words that only end in "ecc" untouched', () => {
const input = 'the decc:thing and tecc:value';
assert.strictEqual(stripPluginNamespace(input), input);
}));
record(test('does not touch a colon with no identifier after it', () => {
const input = 'the `ecc:` prefix in plugin mode';
assert.strictEqual(stripPluginNamespace(input), input);
}));
record(test('returns content with no prefix unchanged', () => {
const input = '# Plain markdown\n\nNo namespace here.\n';
assert.strictEqual(stripPluginNamespace(input), input);
}));
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
return failed === 0;
}
const ok = runTests();
process.exit(ok ? 0 : 1);