feat: add ecc2 legacy tool migration import

This commit is contained in:
Affaan Mustafa
2026-04-10 11:49:38 -07:00
parent cee82417db
commit 4ff5a7169f
3 changed files with 473 additions and 4 deletions

View File

@@ -189,6 +189,7 @@ ECC 2.0 now ships a bounded migration audit entrypoint:
- `ecc migrate plan --source ~/.hermes --output migration-plan.md` - `ecc migrate plan --source ~/.hermes --output migration-plan.md`
- `ecc migrate scaffold --source ~/.hermes --output-dir migration-artifacts` - `ecc migrate scaffold --source ~/.hermes --output-dir migration-artifacts`
- `ecc migrate import-skills --source ~/.hermes --output-dir migration-artifacts/skills` - `ecc migrate import-skills --source ~/.hermes --output-dir migration-artifacts/skills`
- `ecc migrate import-tools --source ~/.hermes --output-dir migration-artifacts/tools`
- `ecc migrate import-schedules --source ~/.hermes --dry-run` - `ecc migrate import-schedules --source ~/.hermes --dry-run`
- `ecc migrate import-remote --source ~/.hermes --dry-run` - `ecc migrate import-remote --source ~/.hermes --dry-run`
- `ecc migrate import-env --source ~/.hermes --dry-run` - `ecc migrate import-env --source ~/.hermes --dry-run`

View File

@@ -83,7 +83,7 @@ These stay local and should be configured per operator:
## Suggested Bring-Up Order ## Suggested Bring-Up Order
0. Run `ecc migrate audit --source ~/.hermes` first to inventory the legacy workspace and see which parts already map onto ECC2. 0. Run `ecc migrate audit --source ~/.hermes` first to inventory the legacy workspace and see which parts already map onto ECC2.
0.5. Generate and review artifacts with `ecc migrate plan` / `ecc migrate scaffold`, scaffold reusable legacy skills with `ecc migrate import-skills --output-dir migration-artifacts/skills`, preview recurring jobs with `ecc migrate import-schedules --dry-run`, preview gateway dispatch with `ecc migrate import-remote --dry-run`, preview safe env/service context with `ecc migrate import-env --dry-run`, then import sanitized workspace memory with `ecc migrate import-memory`. 0.5. Generate and review artifacts with `ecc migrate plan` / `ecc migrate scaffold`, scaffold reusable legacy skills with `ecc migrate import-skills --output-dir migration-artifacts/skills`, scaffold legacy tool translation templates with `ecc migrate import-tools --output-dir migration-artifacts/tools`, preview recurring jobs with `ecc migrate import-schedules --dry-run`, preview gateway dispatch with `ecc migrate import-remote --dry-run`, preview safe env/service context with `ecc migrate import-env --dry-run`, then import sanitized workspace memory with `ecc migrate import-memory`.
1. Install ECC and verify the baseline harness setup. 1. Install ECC and verify the baseline harness setup.
2. Install Hermes and point it at ECC-imported skills. 2. Install Hermes and point it at ECC-imported skills.
3. Register the MCP servers you actually use every day. 3. Register the MCP servers you actually use every day.

View File

@@ -659,6 +659,18 @@ enum MigrationCommands {
#[arg(long)] #[arg(long)]
json: bool, json: bool,
}, },
/// Scaffold ECC-native templates from legacy tool scripts
ImportTools {
/// Path to the legacy Hermes/OpenClaw workspace root
#[arg(long)]
source: PathBuf,
/// Directory where imported ECC2 tool artifacts should be written
#[arg(long)]
output_dir: PathBuf,
/// Emit machine-readable JSON instead of the human summary
#[arg(long)]
json: bool,
},
/// Import legacy gateway/dispatch tasks into the ECC2 remote queue /// Import legacy gateway/dispatch tasks into the ECC2 remote queue
ImportRemote { ImportRemote {
/// Path to the legacy Hermes/OpenClaw workspace root /// Path to the legacy Hermes/OpenClaw workspace root
@@ -1128,6 +1140,30 @@ struct LegacySkillTemplateFile {
orchestration_templates: BTreeMap<String, config::OrchestrationTemplateConfig>, orchestration_templates: BTreeMap<String, config::OrchestrationTemplateConfig>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct LegacyToolImportEntry {
source_path: String,
template_name: String,
title: String,
summary: String,
suggested_surface: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct LegacyToolImportReport {
source: String,
output_dir: String,
tools_detected: usize,
templates_generated: usize,
files_written: Vec<String>,
tools: Vec<LegacyToolImportEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
struct LegacyToolTemplateFile {
orchestration_templates: BTreeMap<String, config::OrchestrationTemplateConfig>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
enum LegacyRemoteImportRequestStatus { enum LegacyRemoteImportRequestStatus {
@@ -1991,6 +2027,18 @@ async fn main() -> Result<()> {
println!("{}", format_legacy_skill_import_human(&report)); println!("{}", format_legacy_skill_import_human(&report));
} }
} }
MigrationCommands::ImportTools {
source,
output_dir,
json,
} => {
let report = import_legacy_tools(&source, &output_dir)?;
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
println!("{}", format_legacy_tool_import_human(&report));
}
}
MigrationCommands::ImportRemote { MigrationCommands::ImportRemote {
source, source,
dry_run, dry_run,
@@ -5157,9 +5205,15 @@ fn build_legacy_migration_next_steps(artifacts: &[LegacyMigrationArtifact]) -> V
.to_string(), .to_string(),
); );
} }
if categories.contains("tools") || categories.contains("plugins") { if categories.contains("tools") {
steps.push( steps.push(
"Rebuild valuable tool/plugin wrappers as ECC agents, commands, hooks, or harness runners, keeping only reusable workflow behavior." "Scaffold translated legacy tools with `ecc migrate import-tools --source <legacy-workspace> --output-dir <dir>`, then rebuild the valuable ones as ECC-native commands, hooks, or harness runners instead of shelling back out to the old stack."
.to_string(),
);
}
if categories.contains("plugins") {
steps.push(
"Rebuild valuable bridge plugins as ECC-native hooks, commands, or skills, keeping only reusable workflow behavior."
.to_string(), .to_string(),
); );
} }
@@ -6246,6 +6300,292 @@ fn format_legacy_skill_import_summary_markdown(report: &LegacySkillImportReport)
lines.join("\n") lines.join("\n")
} }
fn import_legacy_tools(source: &Path, output_dir: &Path) -> Result<LegacyToolImportReport> {
let source = source
.canonicalize()
.with_context(|| format!("Legacy workspace not found: {}", source.display()))?;
if !source.is_dir() {
anyhow::bail!(
"Legacy workspace source must be a directory: {}",
source.display()
);
}
let tools_dir = source.join("tools");
let mut report = LegacyToolImportReport {
source: source.display().to_string(),
output_dir: output_dir.display().to_string(),
tools_detected: 0,
templates_generated: 0,
files_written: Vec::new(),
tools: Vec::new(),
};
if !tools_dir.is_dir() {
return Ok(report);
}
let tool_paths = collect_legacy_tool_paths(&tools_dir)?;
if tool_paths.is_empty() {
return Ok(report);
}
fs::create_dir_all(output_dir)
.with_context(|| format!("create legacy tool output dir {}", output_dir.display()))?;
let mut templates = BTreeMap::new();
for path in tool_paths {
let draft = build_legacy_tool_draft(&source, &tools_dir, &path)?;
report.tools_detected += 1;
report.templates_generated += 1;
report.tools.push(LegacyToolImportEntry {
source_path: draft.source_path.clone(),
template_name: draft.template_name.clone(),
title: draft.title.clone(),
summary: draft.summary.clone(),
suggested_surface: draft.suggested_surface.clone(),
});
templates.insert(
draft.template_name.clone(),
config::OrchestrationTemplateConfig {
description: Some(format!(
"Migrated legacy tool scaffold from {}",
draft.source_path
)),
project: Some("legacy-migration".to_string()),
task_group: Some("legacy tool".to_string()),
agent: Some("claude".to_string()),
profile: None,
worktree: Some(false),
steps: vec![config::OrchestrationTemplateStepConfig {
name: Some("operator".to_string()),
task: format!(
"Use the migrated legacy tool context from {}.\nSuggested ECC target surface: {}\nLegacy tool title: {}\nLegacy summary: {}\nLegacy excerpt:\n{}\nRebuild or wrap that behavior as an ECC-native {} for {{{{task}}}}.",
draft.source_path,
draft.suggested_surface,
draft.title,
draft.summary,
draft.excerpt,
draft.suggested_surface
),
agent: None,
profile: None,
worktree: Some(false),
project: Some("legacy-migration".to_string()),
task_group: Some("legacy tool".to_string()),
}],
},
);
}
let templates_path = output_dir.join("ecc2.imported-tools.toml");
fs::write(
&templates_path,
toml::to_string_pretty(&LegacyToolTemplateFile {
orchestration_templates: templates,
})?,
)
.with_context(|| format!("write imported tool templates {}", templates_path.display()))?;
report
.files_written
.push(templates_path.display().to_string());
let summary_path = output_dir.join("imported-tools.md");
fs::write(
&summary_path,
format_legacy_tool_import_summary_markdown(&report),
)
.with_context(|| format!("write imported tool summary {}", summary_path.display()))?;
report
.files_written
.push(summary_path.display().to_string());
Ok(report)
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct LegacyToolDraft {
source_path: String,
template_name: String,
title: String,
summary: String,
excerpt: String,
suggested_surface: String,
}
fn collect_legacy_tool_paths(root: &Path) -> Result<Vec<PathBuf>> {
let mut paths = Vec::new();
collect_legacy_tool_paths_inner(root, &mut paths)?;
paths.sort();
Ok(paths)
}
fn collect_legacy_tool_paths_inner(root: &Path, paths: &mut Vec<PathBuf>) -> Result<()> {
let mut entries = fs::read_dir(root)
.with_context(|| format!("read legacy tools dir {}", root.display()))?
.collect::<std::io::Result<Vec<_>>>()
.with_context(|| format!("read entries under {}", root.display()))?;
entries.sort_by_key(|entry| entry.path());
for entry in entries {
let path = entry.path();
let file_type = entry
.file_type()
.with_context(|| format!("read file type for {}", path.display()))?;
if file_type.is_dir() {
collect_legacy_tool_paths_inner(&path, paths)?;
continue;
}
if file_type.is_file() && is_legacy_tool_candidate(&path) {
paths.push(path);
}
}
Ok(())
}
fn is_legacy_tool_candidate(path: &Path) -> bool {
matches!(
path.extension().and_then(|ext| ext.to_str()),
Some("py" | "js" | "ts" | "mjs" | "cjs" | "sh" | "bash" | "zsh" | "rb" | "pl" | "php")
) || path.extension().is_none()
}
fn build_legacy_tool_draft(
source: &Path,
tools_dir: &Path,
path: &Path,
) -> Result<LegacyToolDraft> {
let body =
fs::read(path).with_context(|| format!("read legacy tool file {}", path.display()))?;
let body = String::from_utf8_lossy(&body).into_owned();
let source_path = path
.strip_prefix(source)
.unwrap_or(path)
.display()
.to_string();
let relative_to_tools = path.strip_prefix(tools_dir).unwrap_or(path);
let title = extract_legacy_tool_title(relative_to_tools);
let summary = extract_legacy_tool_summary(&body).unwrap_or_else(|| title.clone());
let excerpt = extract_legacy_tool_excerpt(&body, 10, 700).unwrap_or_else(|| summary.clone());
let template_name = format!(
"tool_{}",
slugify_legacy_skill_template_name(relative_to_tools)
);
let suggested_surface = classify_legacy_tool_surface(&source_path, &body).to_string();
Ok(LegacyToolDraft {
source_path,
template_name,
title,
summary,
excerpt,
suggested_surface,
})
}
fn extract_legacy_tool_title(relative_path: &Path) -> String {
relative_path
.file_stem()
.and_then(|value| value.to_str())
.map(|value| value.replace(['-', '_'], " "))
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "legacy tool".to_string())
}
fn extract_legacy_tool_summary(body: &str) -> Option<String> {
body.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with("#!"))
.find_map(|line| {
let stripped = line
.trim_start_matches("#")
.trim_start_matches("//")
.trim_start_matches("--")
.trim_start_matches("/*")
.trim_start_matches('*')
.trim();
if stripped.is_empty() {
None
} else {
Some(truncate_connector_text(stripped, 160))
}
})
}
fn extract_legacy_tool_excerpt(body: &str, max_lines: usize, max_chars: usize) -> Option<String> {
let mut lines = Vec::new();
let mut chars = 0usize;
for line in body.lines().map(str::trim).filter(|line| !line.is_empty()) {
if line.starts_with("#!") {
continue;
}
if chars >= max_chars || lines.len() >= max_lines {
break;
}
let remaining = max_chars.saturating_sub(chars);
if remaining == 0 {
break;
}
let truncated = truncate_connector_text(line, remaining);
chars += truncated.len();
lines.push(truncated);
}
if lines.is_empty() {
None
} else {
Some(lines.join("\n"))
}
}
fn classify_legacy_tool_surface(source_path: &str, body: &str) -> &'static str {
let source_lower = source_path.to_ascii_lowercase();
let body_lower = body.to_ascii_lowercase();
if source_lower.contains("hook")
|| body_lower.contains("pretooluse")
|| body_lower.contains("posttooluse")
|| body_lower.contains("notification")
{
"hook"
} else if source_lower.contains("runner")
|| source_lower.contains("agent")
|| body_lower.contains("session_name_flag")
|| body_lower.contains("include-directories")
{
"harness runner"
} else {
"command"
}
}
fn format_legacy_tool_import_summary_markdown(report: &LegacyToolImportReport) -> String {
let mut lines = vec![
"# Imported legacy tools".to_string(),
String::new(),
format!("- Source: `{}`", report.source),
format!("- Output dir: `{}`", report.output_dir),
format!("- Tools detected: {}", report.tools_detected),
format!("- Templates generated: {}", report.templates_generated),
String::new(),
];
if report.tools.is_empty() {
lines.push("No legacy tool scripts were detected.".to_string());
return lines.join("\n");
}
lines.push("## Tools".to_string());
lines.push(String::new());
for tool in &report.tools {
lines.push(format!(
"- `{}` -> `{}`",
tool.source_path, tool.template_name
));
lines.push(format!(" - Title: {}", tool.title));
lines.push(format!(" - Summary: {}", tool.summary));
lines.push(format!(" - Suggested surface: {}", tool.suggested_surface));
}
lines.join("\n")
}
fn build_legacy_remote_add_command(draft: &LegacyRemoteDispatchDraft) -> Option<String> { fn build_legacy_remote_add_command(draft: &LegacyRemoteDispatchDraft) -> Option<String> {
match draft.request_kind { match draft.request_kind {
session::RemoteDispatchKind::Standard => { session::RemoteDispatchKind::Standard => {
@@ -6671,7 +7011,11 @@ fn build_legacy_migration_plan_report(
target_surface: "ECC agents / hooks / commands / harness runners".to_string(), target_surface: "ECC agents / hooks / commands / harness runners".to_string(),
source_paths: artifact.source_paths.clone(), source_paths: artifact.source_paths.clone(),
command_snippets: vec![ command_snippets: vec![
"ecc start --task \"Rebuild one legacy tool as an ECC-native command or hook\"".to_string(), format!(
"ecc migrate import-tools --source {} --output-dir migration-artifacts/tools",
shell_quote_double(&audit.source)
),
"ecc template <template-name> --task \"Rebuild one legacy tool as an ECC-native command, hook, or harness runner\"".to_string(),
], ],
config_snippets: vec![ config_snippets: vec![
"[harness_runners.legacy-runner]\nprogram = \"<runner-binary>\"\nbase_args = []\nproject_markers = [\".legacy-runner\"]".to_string(), "[harness_runners.legacy-runner]\nprogram = \"<runner-binary>\"\nbase_args = []\nproject_markers = [\".legacy-runner\"]".to_string(),
@@ -7081,6 +7425,34 @@ fn format_legacy_skill_import_human(report: &LegacySkillImportReport) -> String
lines.join("\n") lines.join("\n")
} }
fn format_legacy_tool_import_human(report: &LegacyToolImportReport) -> String {
let mut lines = vec![
format!("Legacy tool import complete for {}", report.source),
format!("- output dir {}", report.output_dir),
format!("- tools detected {}", report.tools_detected),
format!("- templates generated {}", report.templates_generated),
];
if !report.files_written.is_empty() {
lines.push("Files".to_string());
for path in &report.files_written {
lines.push(format!("- {}", path));
}
}
if !report.tools.is_empty() {
lines.push("Tools".to_string());
for tool in &report.tools {
lines.push(format!("- {} -> {}", tool.source_path, tool.template_name));
lines.push(format!(" title {}", tool.title));
lines.push(format!(" summary {}", tool.summary));
lines.push(format!(" suggested surface {}", tool.suggested_surface));
}
}
lines.join("\n")
}
fn format_legacy_remote_import_human(report: &LegacyRemoteImportReport) -> String { fn format_legacy_remote_import_human(report: &LegacyRemoteImportReport) -> String {
let mut lines = vec![ let mut lines = vec![
format!( format!(
@@ -9778,6 +10150,37 @@ mod tests {
} }
} }
#[test]
fn cli_parses_migrate_import_tools_command() {
let cli = Cli::try_parse_from([
"ecc",
"migrate",
"import-tools",
"--source",
"/tmp/hermes",
"--output-dir",
"/tmp/out",
"--json",
])
.expect("migrate import-tools should parse");
match cli.command {
Some(Commands::Migrate {
command:
MigrationCommands::ImportTools {
source,
output_dir,
json,
},
}) => {
assert_eq!(source, PathBuf::from("/tmp/hermes"));
assert_eq!(output_dir, PathBuf::from("/tmp/out"));
assert!(json);
}
_ => panic!("expected migrate import-tools subcommand"),
}
}
#[test] #[test]
fn legacy_migration_audit_report_maps_detected_artifacts() -> Result<()> { fn legacy_migration_audit_report_maps_detected_artifacts() -> Result<()> {
let tempdir = TestDir::new("legacy-migration-audit")?; let tempdir = TestDir::new("legacy-migration-audit")?;
@@ -9910,6 +10313,11 @@ mod tests {
)?; )?;
fs::write(root.join("workspace/notes/recovery.md"), "# recovery\n")?; fs::write(root.join("workspace/notes/recovery.md"), "# recovery\n")?;
fs::write(root.join("skills/ecc-imports/research.md"), "# research\n")?; fs::write(root.join("skills/ecc-imports/research.md"), "# research\n")?;
fs::create_dir_all(root.join("tools"))?;
fs::write(
root.join("tools/browser.py"),
"# Verify the billing portal banner\nprint('browser')\n",
)?;
let audit = build_legacy_migration_audit_report(root)?; let audit = build_legacy_migration_audit_report(root)?;
let plan = build_legacy_migration_plan_report(&audit); let plan = build_legacy_migration_plan_report(&audit);
@@ -9992,6 +10400,15 @@ mod tests {
.command_snippets .command_snippets
.iter() .iter()
.any(|command| command.contains("ecc migrate import-skills --source"))); .any(|command| command.contains("ecc migrate import-skills --source")));
let tools_step = plan
.steps
.iter()
.find(|step| step.category == "tools")
.expect("tools step");
assert!(tools_step
.command_snippets
.iter()
.any(|command| command.contains("ecc migrate import-tools --source")));
Ok(()) Ok(())
} }
@@ -10489,6 +10906,57 @@ Route existing installs to portal first before checkout.
Ok(()) Ok(())
} }
#[test]
fn import_legacy_tools_writes_template_artifacts() -> Result<()> {
let tempdir = TestDir::new("legacy-tool-import")?;
let root = tempdir.path();
fs::create_dir_all(root.join("tools/browser"))?;
fs::create_dir_all(root.join("tools/hooks"))?;
fs::write(
root.join("tools/browser/check_portal.py"),
"# Verify the billing portal warning banner\nprint('check banner')\n",
)?;
fs::write(
root.join("tools/hooks/preflight.sh"),
"#!/usr/bin/env bash\n# PretoolUse guard for dangerous commands\nexit 0\n",
)?;
let output_dir = root.join("out");
let report = import_legacy_tools(root, &output_dir)?;
assert_eq!(report.tools_detected, 2);
assert_eq!(report.templates_generated, 2);
assert_eq!(report.files_written.len(), 2);
assert!(report
.tools
.iter()
.any(|tool| tool.template_name == "tool_browser_check_portal_py"));
assert!(report
.tools
.iter()
.any(|tool| tool.template_name == "tool_hooks_preflight_sh"));
assert!(report
.tools
.iter()
.any(|tool| tool.suggested_surface == "command"));
assert!(report
.tools
.iter()
.any(|tool| tool.suggested_surface == "hook"));
let config_text = fs::read_to_string(output_dir.join("ecc2.imported-tools.toml"))?;
assert!(config_text.contains("[orchestration_templates.tool_browser_check_portal_py]"));
assert!(config_text.contains("[orchestration_templates.tool_hooks_preflight_sh]"));
assert!(config_text.contains("Rebuild or wrap that behavior as an ECC-native"));
let summary_text = fs::read_to_string(output_dir.join("imported-tools.md"))?;
assert!(summary_text.contains("tools/browser/check_portal.py"));
assert!(summary_text.contains("tools/hooks/preflight.sh"));
assert!(summary_text.contains("Suggested surface: hook"));
Ok(())
}
#[test] #[test]
fn legacy_migration_scaffold_writes_plan_and_config_files() -> Result<()> { fn legacy_migration_scaffold_writes_plan_and_config_files() -> Result<()> {
let tempdir = TestDir::new("legacy-migration-scaffold")?; let tempdir = TestDir::new("legacy-migration-scaffold")?;