From 5cebc7b2927323749857b8f4484b71cbe9982326 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 7 Apr 2026 15:12:26 +0000 Subject: [PATCH] feat: add agent export/import CLI commands for portable agent bundles Agent export (wp datamachine agents export ) serializes an agent's full identity into a portable bundle: identity files (SOUL.md, MEMORY.md), USER.md template, agent config, pipelines, flows, and associated memory files. Supports --format=zip (default), json, and dir output formats. Agent import (wp datamachine agents import ) recreates an agent from a bundle with ID remapping, slug collision detection, and --dry-run validation. Flows are imported paused to prevent immediate execution. Closes #1022 --- inc/Cli/Commands/AgentsCommand.php | 250 ++++++++ inc/Core/Agents/AgentBundler.php | 955 +++++++++++++++++++++++++++++ 2 files changed, 1205 insertions(+) create mode 100644 inc/Core/Agents/AgentBundler.php diff --git a/inc/Cli/Commands/AgentsCommand.php b/inc/Cli/Commands/AgentsCommand.php index 7aeb8021c..53553029c 100644 --- a/inc/Cli/Commands/AgentsCommand.php +++ b/inc/Cli/Commands/AgentsCommand.php @@ -13,6 +13,7 @@ use WP_CLI; use DataMachine\Cli\BaseCommand; use DataMachine\Abilities\AgentAbilities; +use DataMachine\Core\Agents\AgentBundler; use DataMachine\Core\FilesRepository\DirectoryManager; defined( 'ABSPATH' ) || exit; @@ -888,6 +889,255 @@ public function config( array $args, array $assoc_args ): void { WP_CLI::success( sprintf( 'Config updated for agent "%s".', $agent['agent_slug'] ) ); } + /** + * Export an agent's full identity into a portable bundle. + * + * Exports agent config, identity files (SOUL.md, MEMORY.md), USER.md + * template, pipelines, flows, and associated memory files into a + * portable bundle that can be imported on another Data Machine installation. + * + * ## OPTIONS + * + * + * : Agent slug to export. + * + * [--format=] + * : Bundle format. + * --- + * default: zip + * options: + * - zip + * - json + * - dir + * --- + * + * [--output=] + * : Output path. For zip/json, a file path. For dir, a directory path. + * Defaults to current directory with auto-generated filename. + * + * ## EXAMPLES + * + * # Export as ZIP (default) + * wp datamachine agents export mattic-agent + * + * # Export as JSON + * wp datamachine agents export mattic-agent --format=json + * + * # Export as directory + * wp datamachine agents export mattic-agent --format=dir --output=/tmp/mattic-bundle + * + * # Export to specific file + * wp datamachine agents export mattic-agent --output=/tmp/mattic-agent.zip + * + * @subcommand export + */ + public function export( array $args, array $assoc_args ): void { + $slug = sanitize_title( $args[0] ?? '' ); + $format = $assoc_args['format'] ?? 'zip'; + $output = $assoc_args['output'] ?? null; + + if ( empty( $slug ) ) { + WP_CLI::error( 'Agent slug is required.' ); + return; + } + + WP_CLI::log( sprintf( 'Exporting agent "%s"...', $slug ) ); + + $bundler = new AgentBundler(); + $result = $bundler->export( $slug ); + + if ( ! $result['success'] ) { + WP_CLI::error( $result['error'] ); + return; + } + + $bundle = $result['bundle']; + + // Log what's being exported. + WP_CLI::log( sprintf( ' Agent: %s (%s)', $bundle['agent']['agent_name'], $bundle['agent']['agent_slug'] ) ); + WP_CLI::log( sprintf( ' Files: %d identity file(s)', count( $bundle['files'] ?? array() ) ) ); + WP_CLI::log( sprintf( ' Pipelines: %d', count( $bundle['pipelines'] ?? array() ) ) ); + WP_CLI::log( sprintf( ' Flows: %d', count( $bundle['flows'] ?? array() ) ) ); + + switch ( $format ) { + case 'json': + $output = $output ?? $slug . '-bundle.json'; + $json = $bundler->to_json( $bundle ); + file_put_contents( $output, $json ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + WP_CLI::success( sprintf( 'Bundle exported to %s (%s)', $output, size_format( filesize( $output ) ) ) ); + break; + + case 'dir': + $output = $output ?? $slug . '-bundle'; + if ( is_dir( $output ) ) { + WP_CLI::error( sprintf( 'Directory "%s" already exists. Remove it first or use --output=.', $output ) ); + return; + } + $wrote = $bundler->to_directory( $bundle, $output ); + if ( ! $wrote ) { + WP_CLI::error( 'Failed to write bundle directory.' ); + return; + } + WP_CLI::success( sprintf( 'Bundle exported to directory: %s', $output ) ); + break; + + case 'zip': + default: + $output = $output ?? $slug . '-bundle.zip'; + $wrote = $bundler->to_zip( $bundle, $output ); + if ( ! $wrote ) { + WP_CLI::error( 'Failed to create ZIP archive.' ); + return; + } + WP_CLI::success( sprintf( 'Bundle exported to %s (%s)', $output, size_format( filesize( $output ) ) ) ); + break; + } + } + + /** + * Import an agent from a portable bundle. + * + * Creates a new agent from a previously exported bundle. Pipelines and + * flows are recreated with new IDs. Flows are imported in paused/manual + * state to prevent immediate execution. + * + * ## OPTIONS + * + * + * : Path to the bundle file (.zip or .json) or directory. + * + * [--slug=] + * : Override the agent slug on import (rename). + * + * [--owner=] + * : Owner WordPress user ID, login, or email. Defaults to current user. + * + * [--dry-run] + * : Validate the bundle and show what would be imported without making changes. + * + * [--yes] + * : Skip confirmation prompt. + * + * ## EXAMPLES + * + * # Import from ZIP + * wp datamachine agents import mattic-agent-bundle.zip + * + * # Import with new slug + * wp datamachine agents import mattic-agent-bundle.zip --slug=my-agent + * + * # Import with specific owner + * wp datamachine agents import mattic-agent-bundle.json --owner=chubes + * + * # Dry run to preview + * wp datamachine agents import mattic-agent-bundle.zip --dry-run + * + * # Import from directory + * wp datamachine agents import /tmp/mattic-bundle/ + * + * @subcommand import + */ + public function import_agent( array $args, array $assoc_args ): void { + $path = $args[0] ?? ''; + $new_slug = $assoc_args['slug'] ?? null; + $dry_run = \WP_CLI\Utils\get_flag_value( $assoc_args, 'dry-run', false ); + + if ( empty( $path ) ) { + WP_CLI::error( 'Bundle path is required.' ); + return; + } + + if ( ! file_exists( $path ) ) { + WP_CLI::error( sprintf( 'Path not found: %s', $path ) ); + return; + } + + $bundler = new AgentBundler(); + + // Parse the bundle based on path type. + if ( is_dir( $path ) ) { + $bundle = $bundler->from_directory( $path ); + } elseif ( preg_match( '/\.zip$/i', $path ) ) { + $bundle = $bundler->from_zip( $path ); + } elseif ( preg_match( '/\.json$/i', $path ) ) { + $json = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $bundle = $bundler->from_json( $json ); + } else { + WP_CLI::error( 'Unsupported bundle format. Use .zip, .json, or a directory path.' ); + return; + } + + if ( ! $bundle ) { + WP_CLI::error( 'Failed to parse bundle. Ensure the file is a valid agent bundle.' ); + return; + } + + // Display bundle info. + $agent_data = $bundle['agent'] ?? array(); + $target_slug = $new_slug ? sanitize_title( $new_slug ) : sanitize_title( $agent_data['agent_slug'] ?? 'unknown' ); + + WP_CLI::log( 'Bundle contents:' ); + WP_CLI::log( sprintf( ' Agent: %s (%s)', $agent_data['agent_name'] ?? '(unnamed)', $agent_data['agent_slug'] ?? '(no slug)' ) ); + WP_CLI::log( sprintf( ' Target: %s', $target_slug ) ); + WP_CLI::log( sprintf( ' Files: %d identity file(s)', count( $bundle['files'] ?? array() ) ) ); + WP_CLI::log( sprintf( ' Pipelines: %d', count( $bundle['pipelines'] ?? array() ) ) ); + WP_CLI::log( sprintf( ' Flows: %d', count( $bundle['flows'] ?? array() ) ) ); + WP_CLI::log( sprintf( ' Exported: %s', $bundle['exported_at'] ?? 'unknown' ) ); + + // Resolve owner. + $owner_id = 0; + if ( isset( $assoc_args['owner'] ) ) { + $owner_id = $this->resolveUserId( $assoc_args['owner'] ); + } + + if ( $dry_run ) { + WP_CLI::log( '' ); + WP_CLI::log( WP_CLI::colorize( '%YDry run mode — validating bundle...%n' ) ); + } elseif ( ! isset( $assoc_args['yes'] ) ) { + WP_CLI::confirm( sprintf( 'Import agent "%s"?', $target_slug ) ); + } + + $result = $bundler->import( $bundle, $new_slug, $owner_id, $dry_run ); + + if ( ! $result['success'] ) { + WP_CLI::error( $result['error'] ); + return; + } + + if ( $dry_run ) { + $summary = $result['summary'] ?? array(); + + WP_CLI::log( '' ); + WP_CLI::log( 'Import preview:' ); + WP_CLI::log( sprintf( ' Agent slug: %s', $summary['agent_slug'] ?? $target_slug ) ); + WP_CLI::log( sprintf( ' Agent name: %s', $summary['agent_name'] ?? '(unnamed)' ) ); + WP_CLI::log( sprintf( ' Owner ID: %d', $summary['owner_id'] ?? 0 ) ); + WP_CLI::log( sprintf( ' Files: %d', $summary['files'] ?? 0 ) ); + WP_CLI::log( sprintf( ' Pipelines: %d', $summary['pipelines'] ?? 0 ) ); + WP_CLI::log( sprintf( ' Flows: %d (will be imported paused)', $summary['flows'] ?? 0 ) ); + + $missing = $summary['missing_abilities'] ?? array(); + if ( ! empty( $missing ) ) { + WP_CLI::log( '' ); + WP_CLI::warning( sprintf( '%d ability slug(s) from the bundle are not registered on this site:', count( $missing ) ) ); + foreach ( $missing as $ability ) { + WP_CLI::log( ' - ' . $ability ); + } + } + + WP_CLI::success( 'Dry run complete — no changes made.' ); + return; + } + + $summary = $result['summary'] ?? array(); + WP_CLI::log( '' ); + WP_CLI::log( sprintf( ' Agent ID: %d', $summary['agent_id'] ?? 0 ) ); + WP_CLI::log( sprintf( ' Pipelines: %d imported', $summary['pipelines_imported'] ?? 0 ) ); + WP_CLI::log( sprintf( ' Flows: %d imported (paused)', $summary['flows_imported'] ?? 0 ) ); + + WP_CLI::success( $result['message'] ); + } + /** * Resolve a user identifier to a WordPress user ID. * diff --git a/inc/Core/Agents/AgentBundler.php b/inc/Core/Agents/AgentBundler.php new file mode 100644 index 000000000..c94f2bdb5 --- /dev/null +++ b/inc/Core/Agents/AgentBundler.php @@ -0,0 +1,955 @@ +agents_repo = new Agents(); + $this->pipelines_repo = new Pipelines(); + $this->flows_repo = new Flows(); + $this->directory_manager = new DirectoryManager(); + } + + /** + * Export an agent into a portable bundle array. + * + * @param string $slug Agent slug. + * @return array{success: bool, bundle?: array, error?: string} + */ + public function export( string $slug ): array { + $agent = $this->agents_repo->get_by_slug( sanitize_title( $slug ) ); + + if ( ! $agent ) { + return array( + 'success' => false, + 'error' => sprintf( 'Agent "%s" not found.', $slug ), + ); + } + + $agent_id = (int) $agent['agent_id']; + + // 1. Agent identity. + $bundle = array( + 'bundle_version' => self::BUNDLE_VERSION, + 'exported_at' => gmdate( 'c' ), + 'agent' => array( + 'agent_slug' => $agent['agent_slug'], + 'agent_name' => $agent['agent_name'], + 'agent_config' => $agent['agent_config'] ?? array(), + 'site_scope' => $agent['site_scope'], + 'status' => $agent['status'], + ), + ); + + // 2. Agent identity files (SOUL.md, MEMORY.md). + $bundle['files'] = $this->collect_agent_files( $agent['agent_slug'] ); + + // 3. Owner's USER.md template (without sensitive data). + $owner_id = (int) $agent['owner_id']; + $bundle['user_template'] = $this->collect_user_template( $owner_id ); + + // 4. Pipelines scoped to this agent. + $pipelines = $this->pipelines_repo->get_all_pipelines( null, $agent_id ); + $bundle['pipelines'] = array(); + + foreach ( $pipelines as $pipeline ) { + $pipeline_id = (int) $pipeline['pipeline_id']; + $pipeline_data = array( + 'original_id' => $pipeline_id, + 'pipeline_name' => $pipeline['pipeline_name'], + 'pipeline_config' => $pipeline['pipeline_config'] ?? array(), + ); + + // Collect pipeline memory files from disk. + $pipeline_data['memory_file_contents'] = $this->collect_pipeline_memory_files( $pipeline_id ); + + $bundle['pipelines'][] = $pipeline_data; + } + + // 5. Flows scoped to this agent. + $flows = $this->flows_repo->get_all_flows( null, $agent_id ); + $bundle['flows'] = array(); + + foreach ( $flows as $flow ) { + $flow_id = (int) $flow['flow_id']; + $flow_data = array( + 'original_id' => $flow_id, + 'original_pipeline_id' => (int) $flow['pipeline_id'], + 'flow_name' => $flow['flow_name'], + 'flow_config' => $flow['flow_config'] ?? array(), + 'scheduling_config' => $this->sanitize_scheduling_config( $flow['scheduling_config'] ?? array() ), + ); + + // Collect flow memory files from disk. + $flow_data['memory_file_contents'] = $this->collect_flow_memory_files( + (int) $flow['pipeline_id'], + $flow_id + ); + + $bundle['flows'][] = $flow_data; + } + + // 6. Abilities manifest — list of ability slugs registered system-wide. + // Importers can use this to verify the target has matching abilities. + $bundle['abilities_manifest'] = $this->collect_abilities_manifest(); + + return array( + 'success' => true, + 'bundle' => $bundle, + ); + } + + /** + * Import an agent from a bundle array. + * + * @param array $bundle The bundle data. + * @param string|null $new_slug Optional override slug. + * @param int $owner_id WordPress user ID to own the imported agent. + * @param bool $dry_run If true, validate without writing. + * @return array{success: bool, message?: string, error?: string, summary?: array} + */ + public function import( array $bundle, ?string $new_slug = null, int $owner_id = 0, bool $dry_run = false ): array { + // Validate bundle. + if ( empty( $bundle['bundle_version'] ) || empty( $bundle['agent'] ) ) { + return array( + 'success' => false, + 'error' => 'Invalid bundle: missing bundle_version or agent data.', + ); + } + + $agent_data = $bundle['agent']; + $slug = $new_slug ? sanitize_title( $new_slug ) : sanitize_title( $agent_data['agent_slug'] ); + + // Check for slug collision. + $existing = $this->agents_repo->get_by_slug( $slug ); + if ( $existing ) { + return array( + 'success' => false, + 'error' => sprintf( 'Agent slug "%s" already exists. Use --slug= to rename on import.', $slug ), + ); + } + + // Resolve owner. + if ( $owner_id <= 0 ) { + $owner_id = get_current_user_id(); + if ( $owner_id <= 0 ) { + // WP-CLI context: fall back to first admin. + $admins = get_users( array( + 'role' => 'administrator', + 'number' => 1, + 'fields' => 'ID', + ) ); + $owner_id = ! empty( $admins ) ? (int) $admins[0] : 1; + } + } + + // Build summary for dry-run reporting. + $summary = array( + 'agent_slug' => $slug, + 'agent_name' => $agent_data['agent_name'], + 'owner_id' => $owner_id, + 'files' => count( $bundle['files'] ?? array() ), + 'pipelines' => count( $bundle['pipelines'] ?? array() ), + 'flows' => count( $bundle['flows'] ?? array() ), + 'has_user_template' => ! empty( $bundle['user_template'] ), + ); + + if ( $dry_run ) { + // Check ability mismatches. + $missing_abilities = $this->check_abilities_manifest( $bundle['abilities_manifest'] ?? array() ); + $summary['missing_abilities'] = $missing_abilities; + + return array( + 'success' => true, + 'message' => 'Dry run — no changes made.', + 'summary' => $summary, + ); + } + + // --- Actual import --- + + // 1. Create agent record. + $config = $agent_data['agent_config'] ?? array(); + $agent_id = $this->agents_repo->create_if_missing( + $slug, + $agent_data['agent_name'] ?? $slug, + $owner_id, + is_array( $config ) ? $config : array() + ); + + if ( ! $agent_id ) { + return array( + 'success' => false, + 'error' => 'Failed to create agent record.', + ); + } + + // 2. Write agent identity files. + $this->write_agent_files( $slug, $bundle['files'] ?? array() ); + + // 3. Write USER.md template if provided. + if ( ! empty( $bundle['user_template'] ) ) { + $this->write_user_template( $owner_id, $bundle['user_template'] ); + } + + // 4. Import pipelines — build old→new ID map. + $pipeline_id_map = array(); // old_id => new_id. + foreach ( $bundle['pipelines'] ?? array() as $pipeline_data ) { + $old_id = (int) ( $pipeline_data['original_id'] ?? 0 ); + $new_pipeline_id = $this->pipelines_repo->create_pipeline( array( + 'pipeline_name' => $pipeline_data['pipeline_name'], + 'pipeline_config' => $pipeline_data['pipeline_config'] ?? array(), + 'agent_id' => $agent_id, + 'user_id' => $owner_id, + ) ); + + if ( $new_pipeline_id ) { + $pipeline_id_map[ $old_id ] = (int) $new_pipeline_id; + + // Write pipeline memory files to disk. + $this->write_pipeline_memory_files( + (int) $new_pipeline_id, + $pipeline_data['memory_file_contents'] ?? array() + ); + } + } + + // 5. Import flows — remap pipeline IDs, import paused. + $flow_count = 0; + foreach ( $bundle['flows'] ?? array() as $flow_data ) { + $old_pipeline_id = (int) ( $flow_data['original_pipeline_id'] ?? 0 ); + $new_pipeline_id = $pipeline_id_map[ $old_pipeline_id ] ?? null; + + if ( ! $new_pipeline_id ) { + continue; // Skip orphan flows. + } + + // Force paused/manual scheduling on import. + $scheduling = $flow_data['scheduling_config'] ?? array(); + $scheduling['enabled'] = false; + if ( ! isset( $scheduling['interval'] ) || 'manual' !== $scheduling['interval'] ) { + $scheduling['_original_interval'] = $scheduling['interval'] ?? 'manual'; + $scheduling['interval'] = 'manual'; + } + + $flow_config = $flow_data['flow_config'] ?? array(); + + // Remap pipeline step IDs inside flow_config. + $flow_config = $this->remap_flow_step_ids( $flow_config, $old_pipeline_id, $new_pipeline_id ); + + $new_flow_id = $this->flows_repo->create_flow( array( + 'pipeline_id' => $new_pipeline_id, + 'flow_name' => $flow_data['flow_name'], + 'flow_config' => $flow_config, + 'scheduling_config' => $scheduling, + 'agent_id' => $agent_id, + 'user_id' => $owner_id, + ) ); + + if ( $new_flow_id ) { + ++$flow_count; + + // Write flow memory files to disk. + $this->write_flow_memory_files( + $new_pipeline_id, + (int) $new_flow_id, + $flow_data['memory_file_contents'] ?? array() + ); + } + } + + $summary['agent_id'] = $agent_id; + $summary['pipelines_imported'] = count( $pipeline_id_map ); + $summary['flows_imported'] = $flow_count; + + return array( + 'success' => true, + 'message' => sprintf( + 'Agent "%s" imported successfully (ID: %d, %d pipeline(s), %d flow(s)).', + $slug, + $agent_id, + count( $pipeline_id_map ), + $flow_count + ), + 'summary' => $summary, + ); + } + + /** + * Collect agent identity files from disk. + * + * @param string $slug Agent slug. + * @return array filename => content. + */ + private function collect_agent_files( string $slug ): array { + $agent_dir = $this->directory_manager->get_agent_identity_directory( $slug ); + $files = array(); + + if ( ! is_dir( $agent_dir ) ) { + return $files; + } + + $identity_files = array( 'SOUL.md', 'MEMORY.md' ); + + foreach ( $identity_files as $filename ) { + $path = $agent_dir . '/' . $filename; + if ( file_exists( $path ) ) { + $files[ $filename ] = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + } + } + + // Also collect any custom .md files in the agent directory (contexts, etc.). + $context_dir = $agent_dir . '/contexts'; + if ( is_dir( $context_dir ) ) { + $iterator = new \DirectoryIterator( $context_dir ); + foreach ( $iterator as $file ) { + if ( $file->isDot() || ! $file->isFile() ) { + continue; + } + $relative_path = 'contexts/' . $file->getFilename(); + $files[ $relative_path ] = file_get_contents( $file->getPathname() ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + } + } + + return $files; + } + + /** + * Collect USER.md template for the agent owner. + * + * @param int $user_id Owner user ID. + * @return string USER.md content or empty string. + */ + private function collect_user_template( int $user_id ): string { + $user_dir = $this->directory_manager->get_user_directory( $user_id ); + $path = $user_dir . '/USER.md'; + + if ( file_exists( $path ) ) { + return file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + } + + return ''; + } + + /** + * Collect memory files stored on disk for a pipeline. + * + * @param int $pipeline_id Pipeline ID. + * @return array filename => content. + */ + private function collect_pipeline_memory_files( int $pipeline_id ): array { + $pipeline_dir = $this->directory_manager->get_pipeline_directory( $pipeline_id ); + return $this->collect_directory_files( $pipeline_dir ); + } + + /** + * Collect memory files stored on disk for a flow. + * + * @param int $pipeline_id Pipeline ID. + * @param int $flow_id Flow ID. + * @return array filename => content. + */ + private function collect_flow_memory_files( int $pipeline_id, int $flow_id ): array { + $flow_dir = $this->directory_manager->get_flow_directory( $pipeline_id, $flow_id ); + $files = $this->collect_directory_files( $flow_dir ); + + // Also collect flow-specific files directory. + $flow_files_dir = $this->directory_manager->get_flow_files_directory( $pipeline_id, $flow_id ); + $flow_files = $this->collect_directory_files( $flow_files_dir, 'files/' ); + + return array_merge( $files, $flow_files ); + } + + /** + * Collect all files from a directory (non-recursive, .md and .txt only). + * + * @param string $directory Directory path. + * @param string $prefix Path prefix for keys. + * @return array relative_path => content. + */ + private function collect_directory_files( string $directory, string $prefix = '' ): array { + $files = array(); + + if ( ! is_dir( $directory ) ) { + return $files; + } + + $iterator = new \DirectoryIterator( $directory ); + foreach ( $iterator as $file ) { + if ( $file->isDot() || ! $file->isFile() ) { + continue; + } + + // Only include text-based files. + $ext = strtolower( $file->getExtension() ); + if ( ! in_array( $ext, array( 'md', 'txt', 'json', 'yaml', 'yml', 'csv' ), true ) ) { + continue; + } + + // Skip job directories. + if ( str_starts_with( $file->getFilename(), 'job-' ) ) { + continue; + } + + $relative_path = $prefix . $file->getFilename(); + $files[ $relative_path ] = file_get_contents( $file->getPathname() ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + } + + return $files; + } + + /** + * Collect abilities manifest — all registered ability slugs. + * + * @return array List of ability slugs. + */ + private function collect_abilities_manifest(): array { + if ( ! function_exists( 'wp_get_abilities' ) ) { + return array(); + } + + $abilities = wp_get_abilities(); + return array_keys( $abilities ); + } + + /** + * Check which abilities from the manifest are missing. + * + * @param array $manifest List of ability slugs. + * @return array Missing ability slugs. + */ + private function check_abilities_manifest( array $manifest ): array { + if ( empty( $manifest ) || ! function_exists( 'wp_get_ability' ) ) { + return array(); + } + + $missing = array(); + foreach ( $manifest as $slug ) { + if ( ! wp_get_ability( $slug ) ) { + $missing[] = $slug; + } + } + + return $missing; + } + + /** + * Sanitize scheduling config for export. + * + * Removes runtime state that shouldn't be exported. + * + * @param array $config Scheduling config. + * @return array Cleaned config. + */ + private function sanitize_scheduling_config( array $config ): array { + // Remove runtime-only fields. + unset( $config['last_run'] ); + unset( $config['next_run'] ); + unset( $config['run_count'] ); + + return $config; + } + + /** + * Write agent identity files to disk. + * + * @param string $slug Agent slug. + * @param array $files filename => content map. + */ + private function write_agent_files( string $slug, array $files ): void { + $agent_dir = $this->directory_manager->get_agent_identity_directory( $slug ); + $this->directory_manager->ensure_directory_exists( $agent_dir ); + + foreach ( $files as $relative_path => $content ) { + $full_path = $agent_dir . '/' . $relative_path; + + // Ensure subdirectories exist (e.g., contexts/). + $dir = dirname( $full_path ); + if ( ! is_dir( $dir ) ) { + wp_mkdir_p( $dir ); + } + + file_put_contents( $full_path, $content ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + } + } + + /** + * Write USER.md template for the new owner. + * + * Only writes if USER.md doesn't already exist (don't overwrite). + * + * @param int $owner_id Owner user ID. + * @param string $content USER.md content. + */ + private function write_user_template( int $owner_id, string $content ): void { + $user_dir = $this->directory_manager->get_user_directory( $owner_id ); + $path = $user_dir . '/USER.md'; + + // Don't overwrite existing USER.md. + if ( file_exists( $path ) ) { + return; + } + + if ( ! is_dir( $user_dir ) ) { + wp_mkdir_p( $user_dir ); + } + + file_put_contents( $path, $content ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + } + + /** + * Write pipeline memory files to disk at the new pipeline's directory. + * + * @param int $pipeline_id New pipeline ID. + * @param array $files filename => content map. + */ + private function write_pipeline_memory_files( int $pipeline_id, array $files ): void { + if ( empty( $files ) ) { + return; + } + + $pipeline_dir = $this->directory_manager->get_pipeline_directory( $pipeline_id ); + $this->directory_manager->ensure_directory_exists( $pipeline_dir ); + + foreach ( $files as $filename => $content ) { + $full_path = $pipeline_dir . '/' . $filename; + $dir = dirname( $full_path ); + if ( ! is_dir( $dir ) ) { + wp_mkdir_p( $dir ); + } + file_put_contents( $full_path, $content ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + } + } + + /** + * Write flow memory files to disk at the new flow's directory. + * + * @param int $pipeline_id New pipeline ID. + * @param int $flow_id New flow ID. + * @param array $files filename => content map. + */ + private function write_flow_memory_files( int $pipeline_id, int $flow_id, array $files ): void { + if ( empty( $files ) ) { + return; + } + + $flow_dir = $this->directory_manager->get_flow_directory( $pipeline_id, $flow_id ); + $this->directory_manager->ensure_directory_exists( $flow_dir ); + + foreach ( $files as $relative_path => $content ) { + // Handle files/ prefix for flow files directory. + if ( str_starts_with( $relative_path, 'files/' ) ) { + $flow_files_dir = $this->directory_manager->get_flow_files_directory( $pipeline_id, $flow_id ); + if ( ! is_dir( $flow_files_dir ) ) { + wp_mkdir_p( $flow_files_dir ); + } + $filename = substr( $relative_path, 6 ); // Strip 'files/' prefix. + $full_path = $flow_files_dir . '/' . $filename; + } else { + $full_path = $flow_dir . '/' . $relative_path; + } + + $dir = dirname( $full_path ); + if ( ! is_dir( $dir ) ) { + wp_mkdir_p( $dir ); + } + file_put_contents( $full_path, $content ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + } + } + + /** + * Remap pipeline step IDs inside a flow config. + * + * Pipeline step IDs have the format {pipeline_id}_{uuid}. When importing, + * the pipeline ID changes, so we need to rewrite these keys. + * + * @param array $flow_config Flow config. + * @param int $old_pipeline_id Original pipeline ID. + * @param int $new_pipeline_id New pipeline ID. + * @return array Updated flow config. + */ + private function remap_flow_step_ids( array $flow_config, int $old_pipeline_id, int $new_pipeline_id ): array { + if ( $old_pipeline_id === $new_pipeline_id ) { + return $flow_config; + } + + $remapped = array(); + $prefix = $old_pipeline_id . '_'; + + foreach ( $flow_config as $key => $value ) { + // Remap step ID keys that start with old pipeline ID. + if ( is_string( $key ) && str_starts_with( $key, $prefix ) ) { + $new_key = $new_pipeline_id . '_' . substr( $key, strlen( $prefix ) ); + $remapped[ $new_key ] = $value; + } else { + $remapped[ $key ] = $value; + } + } + + return $remapped; + } + + /** + * Serialize a bundle to JSON string. + * + * @param array $bundle Bundle data. + * @return string JSON string. + */ + public function to_json( array $bundle ): string { + return wp_json_encode( $bundle, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); + } + + /** + * Parse a bundle from JSON string. + * + * @param string $json JSON string. + * @return array|null Bundle data or null on parse failure. + */ + public function from_json( string $json ): ?array { + $bundle = json_decode( $json, true ); + + if ( ! is_array( $bundle ) ) { + return null; + } + + return $bundle; + } + + /** + * Write bundle as a directory of files. + * + * @param array $bundle Bundle data. + * @param string $directory Target directory. + * @return bool True on success. + */ + public function to_directory( array $bundle, string $directory ): bool { + if ( ! wp_mkdir_p( $directory ) ) { + return false; + } + + // Write manifest.json (everything except file contents). + $manifest = $bundle; + unset( $manifest['files'] ); + $manifest['pipelines'] = array_map( function ( $p ) { + unset( $p['memory_file_contents'] ); + return $p; + }, $manifest['pipelines'] ?? array() ); + $manifest['flows'] = array_map( function ( $f ) { + unset( $f['memory_file_contents'] ); + return $f; + }, $manifest['flows'] ?? array() ); + unset( $manifest['user_template'] ); + + file_put_contents( // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + $directory . '/manifest.json', + wp_json_encode( $manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) + ); + + // Write agent identity files. + $agent_dir = $directory . '/agent'; + wp_mkdir_p( $agent_dir ); + foreach ( $bundle['files'] ?? array() as $filename => $content ) { + $path = $agent_dir . '/' . $filename; + $dir = dirname( $path ); + if ( ! is_dir( $dir ) ) { + wp_mkdir_p( $dir ); + } + file_put_contents( $path, $content ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + } + + // Write USER.md template. + if ( ! empty( $bundle['user_template'] ) ) { + file_put_contents( $directory . '/USER.md', $bundle['user_template'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + } + + // Write pipeline memory files. + foreach ( $bundle['pipelines'] ?? array() as $i => $pipeline ) { + $pipeline_slug = sanitize_title( $pipeline['pipeline_name'] ); + $pipeline_dir = $directory . '/pipelines/' . $i . '-' . $pipeline_slug; + foreach ( $pipeline['memory_file_contents'] ?? array() as $filename => $content ) { + $path = $pipeline_dir . '/' . $filename; + $dir = dirname( $path ); + if ( ! is_dir( $dir ) ) { + wp_mkdir_p( $dir ); + } + file_put_contents( $path, $content ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + } + } + + // Write flow memory files. + foreach ( $bundle['flows'] ?? array() as $i => $flow ) { + $flow_slug = sanitize_title( $flow['flow_name'] ); + $flow_dir = $directory . '/flows/' . $i . '-' . $flow_slug; + foreach ( $flow['memory_file_contents'] ?? array() as $filename => $content ) { + $path = $flow_dir . '/' . $filename; + $dir = dirname( $path ); + if ( ! is_dir( $dir ) ) { + wp_mkdir_p( $dir ); + } + file_put_contents( $path, $content ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + } + } + + return true; + } + + /** + * Read bundle from a directory export. + * + * @param string $directory Source directory. + * @return array|null Bundle data or null on failure. + */ + public function from_directory( string $directory ): ?array { + $manifest_path = $directory . '/manifest.json'; + if ( ! file_exists( $manifest_path ) ) { + return null; + } + + $manifest = json_decode( file_get_contents( $manifest_path ), true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + if ( ! is_array( $manifest ) ) { + return null; + } + + $bundle = $manifest; + + // Read agent identity files. + $agent_dir = $directory . '/agent'; + $bundle['files'] = array(); + if ( is_dir( $agent_dir ) ) { + $bundle['files'] = $this->read_directory_recursive( $agent_dir ); + } + + // Read USER.md template. + $user_md_path = $directory . '/USER.md'; + $bundle['user_template'] = file_exists( $user_md_path ) + ? file_get_contents( $user_md_path ) // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + : ''; + + // Read pipeline memory files. + $pipelines_dir = $directory . '/pipelines'; + if ( is_dir( $pipelines_dir ) ) { + $pipeline_dirs = glob( $pipelines_dir . '/*', GLOB_ONLYDIR ); + foreach ( $pipeline_dirs as $i => $pipeline_dir ) { + if ( isset( $bundle['pipelines'][ $i ] ) ) { + $bundle['pipelines'][ $i ]['memory_file_contents'] = $this->read_directory_recursive( $pipeline_dir ); + } + } + } + + // Read flow memory files. + $flows_dir = $directory . '/flows'; + if ( is_dir( $flows_dir ) ) { + $flow_dirs = glob( $flows_dir . '/*', GLOB_ONLYDIR ); + foreach ( $flow_dirs as $i => $flow_dir ) { + if ( isset( $bundle['flows'][ $i ] ) ) { + $bundle['flows'][ $i ]['memory_file_contents'] = $this->read_directory_recursive( $flow_dir ); + } + } + } + + return $bundle; + } + + /** + * Read all text files from a directory recursively. + * + * @param string $directory Directory to read. + * @param string $prefix Path prefix. + * @return array relative_path => content. + */ + private function read_directory_recursive( string $directory, string $prefix = '' ): array { + $files = array(); + + if ( ! is_dir( $directory ) ) { + return $files; + } + + $iterator = new \DirectoryIterator( $directory ); + foreach ( $iterator as $item ) { + if ( $item->isDot() ) { + continue; + } + + $relative = $prefix . $item->getFilename(); + + if ( $item->isDir() ) { + $sub_files = $this->read_directory_recursive( $item->getPathname(), $relative . '/' ); + $files = array_merge( $files, $sub_files ); + } elseif ( $item->isFile() ) { + $ext = strtolower( $item->getExtension() ); + if ( in_array( $ext, array( 'md', 'txt', 'json', 'yaml', 'yml', 'csv' ), true ) ) { + $files[ $relative ] = file_get_contents( $item->getPathname() ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + } + } + } + + return $files; + } + + /** + * Create a ZIP archive from a bundle. + * + * @param array $bundle Bundle data. + * @param string $zip_path Path for the ZIP file. + * @return bool True on success. + */ + public function to_zip( array $bundle, string $zip_path ): bool { + // Write to temp directory first, then zip. + $temp_dir = sys_get_temp_dir() . '/dm-agent-export-' . uniqid(); + wp_mkdir_p( $temp_dir ); + + $slug = sanitize_title( $bundle['agent']['agent_slug'] ?? 'agent' ); + $sub_dir = $temp_dir . '/' . $slug; + + $wrote = $this->to_directory( $bundle, $sub_dir ); + if ( ! $wrote ) { + $this->rm_rf( $temp_dir ); + return false; + } + + // Create ZIP. + $zip = new \ZipArchive(); + if ( true !== $zip->open( $zip_path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE ) ) { + $this->rm_rf( $temp_dir ); + return false; + } + + $this->add_directory_to_zip( $zip, $sub_dir, $slug ); + $zip->close(); + + $this->rm_rf( $temp_dir ); + return true; + } + + /** + * Read a bundle from a ZIP archive. + * + * @param string $zip_path Path to ZIP file. + * @return array|null Bundle data or null on failure. + */ + public function from_zip( string $zip_path ): ?array { + $zip = new \ZipArchive(); + if ( true !== $zip->open( $zip_path ) ) { + return null; + } + + $temp_dir = sys_get_temp_dir() . '/dm-agent-import-' . uniqid(); + wp_mkdir_p( $temp_dir ); + + $zip->extractTo( $temp_dir ); + $zip->close(); + + // Find the manifest.json — it might be in a subdirectory. + $bundle = null; + + if ( file_exists( $temp_dir . '/manifest.json' ) ) { + $bundle = $this->from_directory( $temp_dir ); + } else { + // Look one level deep. + $subdirs = glob( $temp_dir . '/*', GLOB_ONLYDIR ); + foreach ( $subdirs as $subdir ) { + if ( file_exists( $subdir . '/manifest.json' ) ) { + $bundle = $this->from_directory( $subdir ); + break; + } + } + } + + $this->rm_rf( $temp_dir ); + return $bundle; + } + + /** + * Add a directory to a ZIP archive recursively. + * + * @param \ZipArchive $zip ZIP archive. + * @param string $directory Directory to add. + * @param string $prefix Path prefix in ZIP. + */ + private function add_directory_to_zip( \ZipArchive $zip, string $directory, string $prefix ): void { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( $directory, \RecursiveDirectoryIterator::SKIP_DOTS ), + \RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ( $iterator as $item ) { + $relative_path = $prefix . '/' . substr( $item->getPathname(), strlen( $directory ) + 1 ); + + if ( $item->isDir() ) { + $zip->addEmptyDir( $relative_path ); + } else { + $zip->addFile( $item->getPathname(), $relative_path ); + } + } + } + + /** + * Recursively remove a directory. + * + * @param string $dir Directory to remove. + */ + private function rm_rf( string $dir ): void { + if ( ! is_dir( $dir ) ) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( $dir, \RecursiveDirectoryIterator::SKIP_DOTS ), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ( $iterator as $item ) { + if ( $item->isDir() ) { + rmdir( $item->getPathname() ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir + } else { + unlink( $item->getPathname() ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink + } + } + + rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir + } +}