Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,7 @@
{
"name": "harness-engineering",
"description": "Readiness analysis and enforcement scaffolding for AI coding agent projects.",
"source": {
"source": "github",
"repo": "jrenaldi79/harness-engineering"
},
"source": "./",
"homepage": "https://github.com/jrenaldi79/harness-engineering"
}
]
Expand Down
13 changes: 13 additions & 0 deletions tests/evals/test-marketplace-install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,19 @@ fi

pass "Plugin installed successfully"

# Check for recursive cache nesting (ENAMETOOLONG bug)
# If the plugin source is self-referential, Claude Code creates deeply nested
# directories like harness-engineering/1.0.0/harness-engineering/1.0.0/...
CACHE_DIR="$HOME/.claude/plugins/cache"
if [ -d "$CACHE_DIR" ]; then
NESTED=$(find "$CACHE_DIR" -maxdepth 6 -type d -name "$PLUGIN_NAME" 2>/dev/null | wc -l)
if [ "$NESTED" -gt 2 ]; then
fail "Recursive cache nesting detected ($NESTED nested '$PLUGIN_NAME' dirs in cache). Self-referential source in marketplace.json?"
else
pass "No recursive cache nesting"
fi
fi

# Verify plugin is listed
PLUGIN_LIST=$(cd "$TMP_DIR" && claude plugin list 2>/dev/null) || true
echo "$PLUGIN_LIST" > "$RESULT_DIR/plugin-list-output.txt"
Expand Down
54 changes: 49 additions & 5 deletions tests/scripts/marketplace-schema.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
* so that `claude plugin marketplace add` can register the marketplace
* and `claude plugin install` can install plugins from it.
*
* Also detects self-referential plugin sources that cause recursive
* caching (ENAMETOOLONG errors during plugin install).
*
* Can be run standalone: node tests/scripts/marketplace-schema.test.js
* Or via Jest if a jest config is present.
*/
Expand All @@ -21,6 +24,26 @@ const MARKETPLACE_PATH = path.resolve(

const VALID_SOURCE_TYPES = ['github', 'url', 'git-subdir', 'npm'];

/**
* Derive the marketplace's GitHub repo identifier (owner/repo, lowercase)
* from plugin homepage URLs or the marketplace name. Returns null if the
* repo cannot be determined.
*/
function deriveMarketplaceRepo(manifest) {
// Check plugin homepage URLs for GitHub repo references
if (Array.isArray(manifest.plugins)) {
for (const p of manifest.plugins) {
if (typeof p.homepage === 'string') {
const match = p.homepage.match(
/github\.com\/([^/]+\/[^/]+?)(?:\.git)?(?:\/|$)/
);
if (match) return match[1].toLowerCase();
}
}
}
return null;
}

function validate() {
const raw = fs.readFileSync(MARKETPLACE_PATH, 'utf8');
const manifest = JSON.parse(raw);
Expand Down Expand Up @@ -60,6 +83,11 @@ function validate() {
if (!Array.isArray(manifest.plugins) || manifest.plugins.length === 0) {
errors.push('plugins must be a non-empty array');
} else {
// Derive the marketplace repo from the homepage or known repo name.
// Used to detect self-referential GitHub sources that cause recursive
// caching and ENAMETOOLONG errors during plugin install.
const marketplaceRepo = deriveMarketplaceRepo(manifest);

for (let i = 0; i < manifest.plugins.length; i++) {
const p = manifest.plugins[i];
const prefix = `plugins[${i}]`;
Expand All @@ -68,21 +96,37 @@ function validate() {
errors.push(`${prefix}.name must be a non-empty string`);
}

// source must be an object with a valid source type
// source can be a relative path string (e.g. "./plugins/my-plugin")
// or an object with a source type
if (typeof p.source === 'string') {
errors.push(
`${prefix}.source must be an object (e.g. {"source": "github", "repo": "owner/repo"}), got string "${p.source}"`
);
if (!p.source.startsWith('./')) {
errors.push(
`${prefix}.source string must start with "./" (relative path), got "${p.source}"`
);
}
} else if (
typeof p.source !== 'object' ||
p.source === null ||
Array.isArray(p.source)
) {
errors.push(`${prefix}.source must be an object`);
errors.push(`${prefix}.source must be a string (relative path) or an object`);
} else if (!VALID_SOURCE_TYPES.includes(p.source.source)) {
errors.push(
`${prefix}.source.source must be one of: ${VALID_SOURCE_TYPES.join(', ')} (got "${p.source.source}")`
);
} else if (p.source.source === 'github' && marketplaceRepo) {
// Detect self-referential GitHub sources: when a plugin's GitHub
// source points to the same repo that hosts the marketplace, Claude
// Code re-clones the repo inside its own cache on every install,
// creating deeply nested directories until ENAMETOOLONG.
const pluginRepo = (p.source.repo || '').toLowerCase();
if (pluginRepo === marketplaceRepo) {
errors.push(
`${prefix}.source is self-referential: GitHub source "${p.source.repo}" ` +
`points to the same repo as the marketplace. Use a relative path ` +
`(e.g. "source": "./") instead to avoid recursive caching (ENAMETOOLONG).`
);
}
}
}
}
Expand Down
Loading