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
21 changes: 15 additions & 6 deletions crates/forge_app/src/hooks/plugin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ const FORGE_PLUGIN_ROOT: &str = "FORGE_PLUGIN_ROOT";
const FORGE_PLUGIN_DATA: &str = "FORGE_PLUGIN_DATA";
const FORGE_PLUGIN_OPTION_PREFIX: &str = "FORGE_PLUGIN_OPTION_";
const FORGE_ENV_FILE: &str = "FORGE_ENV_FILE";

// Claude Code compatibility aliases — injected alongside the FORGE_*
// counterparts so marketplace plugins that reference $CLAUDE_* variables
// work under Forge without modification.
const CLAUDE_PLUGIN_ROOT: &str = "CLAUDE_PLUGIN_ROOT";
const CLAUDE_PROJECT_DIR: &str = "CLAUDE_PROJECT_DIR";
const CLAUDE_SESSION_ID: &str = "CLAUDE_SESSION_ID";
/// Subdirectory under `base_path` where per-plugin data is stored.
const PLUGIN_DATA_DIR: &str = "plugin-data";

Expand Down Expand Up @@ -260,15 +267,17 @@ impl<S: Services> PluginHookHandler<S> {

// Build FORGE_* env vars from the available context.
let mut env_vars = HashMap::new();
env_vars.insert(
FORGE_PROJECT_DIR.to_string(),
input.base.cwd.display().to_string(),
);
let cwd_str = input.base.cwd.display().to_string();
env_vars.insert(FORGE_PROJECT_DIR.to_string(), cwd_str.clone());
env_vars.insert(CLAUDE_PROJECT_DIR.to_string(), cwd_str);
env_vars
.insert(FORGE_SESSION_ID.to_string(), input.base.session_id.clone());
env_vars
.insert(CLAUDE_SESSION_ID.to_string(), input.base.session_id.clone());
if let Some(ref root) = source.plugin_root {
env_vars
.insert(FORGE_PLUGIN_ROOT.to_string(), root.display().to_string());
let root_str = root.display().to_string();
env_vars.insert(FORGE_PLUGIN_ROOT.to_string(), root_str.clone());
env_vars.insert(CLAUDE_PLUGIN_ROOT.to_string(), root_str);
}
if let Some(ref name) = source.plugin_name {
let data_dir = base_path.join(PLUGIN_DATA_DIR).join(name);
Expand Down
132 changes: 132 additions & 0 deletions crates/forge_domain/src/plugin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,47 @@ pub struct PluginHooksConfig {
pub raw: serde_json::Value,
}

/// Claude Code marketplace manifest, parsed from `marketplace.json`.
///
/// A marketplace directory wraps one or more plugins with a `source` field
/// that points to the real plugin root relative to the `marketplace.json`
/// location. Forge uses this indirection to discover plugins inside
/// `~/.claude/plugins/marketplaces/<author>/` hierarchies.
///
/// Reference: Claude Code's marketplace install flow.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MarketplaceManifest {
/// Display name of the marketplace (usually the author/org handle).
#[serde(default)]
pub name: Option<String>,

/// One or more plugin entries with their source paths.
#[serde(default)]
pub plugins: Vec<MarketplacePluginEntry>,
}

/// A single plugin entry inside a [`MarketplaceManifest`].
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MarketplacePluginEntry {
/// Plugin name (may differ from the directory name).
#[serde(default)]
pub name: Option<String>,

/// Relative path from the marketplace root to the plugin directory
/// (e.g. `"./plugin"`).
pub source: String,

/// Plugin version string.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,

/// Plugin description.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}

/// Where a plugin was discovered. Used by the loader for precedence rules
/// (Project > Global > Builtin) and shown to the user in `:plugin list`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
Expand Down Expand Up @@ -515,6 +556,97 @@ mod tests {
assert_eq!(actual, expected);
}

#[test]
fn test_parse_marketplace_manifest_full() {
let json = r#"{
"name": "test-author",
"plugins": [
{
"name": "inner-plugin",
"source": "./plugin",
"version": "1.0.0",
"description": "Nested plugin inside marketplace"
}
]
}"#;
let actual: MarketplaceManifest = serde_json::from_str(json).unwrap();
let expected = MarketplaceManifest {
name: Some("test-author".to_string()),
plugins: vec![MarketplacePluginEntry {
name: Some("inner-plugin".to_string()),
source: "./plugin".to_string(),
version: Some("1.0.0".to_string()),
description: Some("Nested plugin inside marketplace".to_string()),
}],
};
assert_eq!(actual, expected);
}

#[test]
fn test_parse_marketplace_manifest_minimal() {
let json = r#"{ "plugins": [{ "source": "./plugin" }] }"#;
let actual: MarketplaceManifest = serde_json::from_str(json).unwrap();
assert_eq!(actual.name, None);
assert_eq!(actual.plugins.len(), 1);
assert_eq!(actual.plugins[0].source, "./plugin");
assert_eq!(actual.plugins[0].name, None);
assert_eq!(actual.plugins[0].version, None);
assert_eq!(actual.plugins[0].description, None);
}

#[test]
fn test_parse_marketplace_manifest_empty_plugins() {
let json = r#"{ "name": "empty-marketplace" }"#;
let actual: MarketplaceManifest = serde_json::from_str(json).unwrap();
assert_eq!(actual.name, Some("empty-marketplace".to_string()));
assert!(actual.plugins.is_empty());
}

#[test]
fn test_parse_marketplace_manifest_multiple_plugins() {
let json = r#"{
"name": "multi-author",
"plugins": [
{ "name": "plugin-a", "source": "./a" },
{ "name": "plugin-b", "source": "./b", "version": "2.0.0" }
]
}"#;
let actual: MarketplaceManifest = serde_json::from_str(json).unwrap();
assert_eq!(actual.plugins.len(), 2);
assert_eq!(actual.plugins[0].name.as_deref(), Some("plugin-a"));
assert_eq!(actual.plugins[0].source, "./a");
assert_eq!(actual.plugins[1].name.as_deref(), Some("plugin-b"));
assert_eq!(actual.plugins[1].source, "./b");
assert_eq!(actual.plugins[1].version.as_deref(), Some("2.0.0"));
}

#[test]
fn test_parse_marketplace_manifest_camel_case_compat() {
// Verify camelCase fields work (Claude Code wire format)
let json = r#"{
"name": "test",
"plugins": [{ "source": "./plugin" }]
}"#;
let actual: MarketplaceManifest = serde_json::from_str(json).unwrap();
assert_eq!(actual.plugins.len(), 1);
}

#[test]
fn test_parse_marketplace_manifest_roundtrip() {
let manifest = MarketplaceManifest {
name: Some("round-trip".to_string()),
plugins: vec![MarketplacePluginEntry {
name: Some("demo".to_string()),
source: "./plugin".to_string(),
version: Some("1.0.0".to_string()),
description: None,
}],
};
let json = serde_json::to_string(&manifest).unwrap();
let actual: MarketplaceManifest = serde_json::from_str(&json).unwrap();
assert_eq!(actual, manifest);
}

#[test]
fn test_plugin_load_result_has_errors_reports_non_empty_errors() {
let result_ok = PluginLoadResult::new(vec![fixture_loaded_plugin("alpha")], Vec::new());
Expand Down
16 changes: 13 additions & 3 deletions crates/forge_main/src/info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -761,13 +761,23 @@ mod tests {
use forge_api::{Environment, EventValue};
use pretty_assertions::assert_eq;

// Helper to create minimal test environment
// Helper to create minimal test environment.
//
// When `home` is `None` the generated `Environment.home` is explicitly
// set to `None` so the test is deterministic. Without this, Faker may
// produce a random `home` that happens to be a prefix of the test path,
// causing `strip_prefix` to succeed and the test to flake.
fn create_env(os: &str, home: Option<&str>) -> Environment {
use fake::{Fake, Faker};
let mut fixture: Environment = Faker.fake();
fixture = fixture.os(os.to_string());
if let Some(home_path) = home {
fixture = fixture.home(PathBuf::from(home_path));
match home {
Some(home_path) => {
fixture.home = Some(PathBuf::from(home_path));
}
None => {
fixture.home = None;
}
}
fixture
}
Expand Down
Loading
Loading