Hmbown/CodeWhale · error
staged bundle has no plugin.json…
Error message
staged bundle has no plugin.json, .claude-plugin/plugin.json, kimi.plugin.json, or plugin.toml
What it means
After staging a plugin bundle, the tree must contain a supported manifest. `resolve_manifest_path` scans the staged path for plugin.json, .claude-plugin/plugin.json, kimi.plugin.json, or plugin.toml; when none exists, staging fails with this error before any install proceeds.
Solutions
- Add a supported manifest (plugin.json etc.) at the bundle root or under .claude-plugin/.
- Repack the tarball so the manifest-bearing directory is the archive root, not nested under a wrapper folder.
- Point install at the actual plugin directory instead of its parent.
- If using kimi.toml/plugin.yaml or another variant, rename to a supported filename.
Example fix
// before: tarball contains wrapper dir tar czf plugin.tgz myplugin/ # myplugin/myplugin/plugin.json // after tar czf plugin.tgz -C myplugin . # plugin.json at archive root
Defensive patterns
Strategy: validation
Validate before calling
const SUPPORTED = ['plugin.json', '.claude-plugin/plugin.json', 'kimi.plugin.json', 'plugin.toml'];
if (!SUPPORTED.some((f) => fs.existsSync(`${stagedDir}/${f}`))) {
throw new Error(`staged tree ${stagedDir} has no supported plugin manifest`);
} Type guard
const hasManifest = (dir) => fs.existsSync(path.join(dir, 'plugin.json')) || fs.existsSync(path.join(dir, '.claude-plugin', 'plugin.json')) || fs.existsSync(path.join(dir, 'kimi.plugin.json')) || fs.existsSync(path.join(dir, 'plugin.toml'));
Try / catch
try {
await codewhale.plugins.installFromTarball(tgzPath);
} catch (e) {
if (e.message.includes('staged bundle has no')) {
console.error('archive layout wrong: manifest must be at root or .claude-plugin/');
} else throw e;
} Prevention
- Build archives so the manifest sits at the archive root (tar -C dir .).
- Never rename the manifest to unsupported filenames.
- Smoke-test install of every packaged artifact in CI.
When it happens
Trigger: `validate_staged` (called from `stage_local_copy` and `stage_tarball`) is invoked on a staged directory/tarball that lacks all four supported manifest filenames.
Common situations: Pointing install at a directory that is not a plugin (a plain source folder or docs repo), a tarball whose manifest is nested several levels deep under a wrapper directory, or a plugin using an unsupported manifest filename.
Understand the failure class
Background: "File not found" and ENOENT errors: why libraries can't find a file that should exist — this error's family across 50 libraries.
Related errors
- installed plugin has no supported manifest
- source is not a valid plugin bundle: no plugin.json…
- source is not a valid plugin bundle
- staged plugin manifest failed validation
- bundle exceeds the file limit
AI-assisted analysis of Hmbown/CodeWhale@73e0f67d83 (2026-09-22).
Data as JSON: /api/errors/9cc568c5de88bce4.
Report an issue: GitHub.
Appendix: source
Thrown at crates/tui/src/plugins/install/stage.rs:73
)
})
}
#[cfg(not(unix))]
fn ensure_plugins_dir(user_plugins_dir: &Path) -> Result<()> {
fs::create_dir_all(user_plugins_dir).with_context(|| {
format!(
"failed to create user plugins directory {}",
user_plugins_dir.display()
)
})
}
/// Validate the staged tree and return the manifest name + content hash.
pub(super) fn validate_staged(staged_path: &Path) -> Result<(String, String)> {
let manifest_path = crate::plugins::agent_plugin::resolve_manifest_path(staged_path)
.ok_or_else(|| {
anyhow::anyhow!("staged bundle has no plugin.json, .claude-plugin/plugin.json, kimi.plugin.json, or plugin.toml")
})?;
let validated = PluginManifest::validate_from_path(&manifest_path)
.map_err(|error| anyhow::anyhow!("staged plugin manifest failed validation: {error}"))?;
let name = validated.manifest.plugin.name.clone();
validate_skill_name_segment(&name).map_err(|error| {
anyhow::anyhow!("[plugin].name is not a safe directory name: {error:#}")
})?;
Ok((name, validated.content_hash))
}
/// Copy a local bundle directory into staging. Symlinks anywhere in the
/// source are rejected; a stale `.installed-from` marker is never copied so
/// provenance always reflects *this* install.
pub(super) fn stage_local_copy(
source: &Path,
user_plugins_dir: &Path,
max_size: u64,
) -> Result<StagedPlugin> {View on GitHub (pinned to 73e0f67d83)