From 4ff5a7169fb7d9ad4b29b8c6e78cc681f1b2fbe4 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 11:49:38 -0700 Subject: [PATCH] feat: add ecc2 legacy tool migration import --- docs/HERMES-OPENCLAW-MIGRATION.md | 1 + docs/HERMES-SETUP.md | 2 +- ecc2/src/main.rs | 474 +++++++++++++++++++++++++++++- 3 files changed, 473 insertions(+), 4 deletions(-) diff --git a/docs/HERMES-OPENCLAW-MIGRATION.md b/docs/HERMES-OPENCLAW-MIGRATION.md index a386f951..1b83d428 100644 --- a/docs/HERMES-OPENCLAW-MIGRATION.md +++ b/docs/HERMES-OPENCLAW-MIGRATION.md @@ -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 scaffold --source ~/.hermes --output-dir migration-artifacts` - `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-remote --source ~/.hermes --dry-run` - `ecc migrate import-env --source ~/.hermes --dry-run` diff --git a/docs/HERMES-SETUP.md b/docs/HERMES-SETUP.md index 83af8abc..111c81c7 100644 --- a/docs/HERMES-SETUP.md +++ b/docs/HERMES-SETUP.md @@ -83,7 +83,7 @@ These stay local and should be configured per operator: ## 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.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. 2. Install Hermes and point it at ECC-imported skills. 3. Register the MCP servers you actually use every day. diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 63b5c87f..aa901663 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -659,6 +659,18 @@ enum MigrationCommands { #[arg(long)] 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 ImportRemote { /// Path to the legacy Hermes/OpenClaw workspace root @@ -1128,6 +1140,30 @@ struct LegacySkillTemplateFile { orchestration_templates: BTreeMap, } +#[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, + tools: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +struct LegacyToolTemplateFile { + orchestration_templates: BTreeMap, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] enum LegacyRemoteImportRequestStatus { @@ -1991,6 +2027,18 @@ async fn main() -> Result<()> { 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 { source, dry_run, @@ -5157,9 +5205,15 @@ fn build_legacy_migration_next_steps(artifacts: &[LegacyMigrationArtifact]) -> V .to_string(), ); } - if categories.contains("tools") || categories.contains("plugins") { + if categories.contains("tools") { 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 --output-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(), ); } @@ -6246,6 +6300,292 @@ fn format_legacy_skill_import_summary_markdown(report: &LegacySkillImportReport) lines.join("\n") } +fn import_legacy_tools(source: &Path, output_dir: &Path) -> Result { + 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> { + 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) -> Result<()> { + let mut entries = fs::read_dir(root) + .with_context(|| format!("read legacy tools dir {}", root.display()))? + .collect::>>() + .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 { + 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 { + 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 { + 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 { match draft.request_kind { session::RemoteDispatchKind::Standard => { @@ -6671,7 +7011,11 @@ fn build_legacy_migration_plan_report( target_surface: "ECC agents / hooks / commands / harness runners".to_string(), source_paths: artifact.source_paths.clone(), 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 --task \"Rebuild one legacy tool as an ECC-native command, hook, or harness runner\"".to_string(), ], config_snippets: vec![ "[harness_runners.legacy-runner]\nprogram = \"\"\nbase_args = []\nproject_markers = [\".legacy-runner\"]".to_string(), @@ -7081,6 +7425,34 @@ fn format_legacy_skill_import_human(report: &LegacySkillImportReport) -> String 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 { let mut lines = vec![ 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] fn legacy_migration_audit_report_maps_detected_artifacts() -> Result<()> { 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("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 plan = build_legacy_migration_plan_report(&audit); @@ -9992,6 +10400,15 @@ mod tests { .command_snippets .iter() .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(()) } @@ -10489,6 +10906,57 @@ Route existing installs to portal first before checkout. 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] fn legacy_migration_scaffold_writes_plan_and_config_files() -> Result<()> { let tempdir = TestDir::new("legacy-migration-scaffold")?;