diff --git a/src/StaticPHP/Command/DumpLicenseCommand.php b/src/StaticPHP/Command/DumpLicenseCommand.php
new file mode 100644
index 00000000..d90ecbf9
--- /dev/null
+++ b/src/StaticPHP/Command/DumpLicenseCommand.php
@@ -0,0 +1,147 @@
+addArgument('artifacts', InputArgument::OPTIONAL, 'Specific artifacts to dump licenses, comma separated, e.g "php-src,openssl,curl"');
+
+ // v2 compatible options
+ $this->addOption('for-extensions', 'e', InputOption::VALUE_REQUIRED, 'Dump by extensions (automatically includes php-src), e.g "openssl,mbstring"');
+ $this->addOption('for-libs', 'l', InputOption::VALUE_REQUIRED, 'Dump by libraries, e.g "openssl,zlib,curl"');
+
+ // v3 options
+ $this->addOption('for-packages', 'p', InputOption::VALUE_REQUIRED, 'Dump by packages, e.g "php,libssl,libcurl"');
+ $this->addOption('dump-dir', 'd', InputOption::VALUE_REQUIRED, 'Target directory for dumped licenses', BUILD_ROOT_PATH . '/license');
+ $this->addOption('without-suggests', null, null, 'Do not include suggested packages when using --for-extensions or --for-packages');
+ }
+
+ public function handle(): int
+ {
+ $dumper = new LicenseDumper();
+ $dump_dir = $this->getOption('dump-dir');
+ $artifacts_to_dump = [];
+
+ // Handle direct artifact argument
+ if ($artifacts = $this->getArgument('artifacts')) {
+ $artifacts_to_dump = array_merge($artifacts_to_dump, parse_comma_list($artifacts));
+ }
+
+ // Handle --for-extensions option
+ if ($exts = $this->getOption('for-extensions')) {
+ $artifacts_to_dump = array_merge(
+ $artifacts_to_dump,
+ $this->resolveFromExtensions(parse_extension_list($exts))
+ );
+ }
+
+ // Handle --for-libs option (v2 compat)
+ if ($libs = $this->getOption('for-libs')) {
+ $artifacts_to_dump = array_merge(
+ $artifacts_to_dump,
+ $this->resolveFromPackages(parse_comma_list($libs))
+ );
+ }
+
+ // Handle --for-packages option
+ if ($packages = $this->getOption('for-packages')) {
+ $artifacts_to_dump = array_merge(
+ $artifacts_to_dump,
+ $this->resolveFromPackages(parse_comma_list($packages))
+ );
+ }
+
+ // Check if any artifacts to dump
+ if (empty($artifacts_to_dump)) {
+ $this->output->writeln('No artifacts specified. Use one of:');
+ $this->output->writeln(' - Direct argument: dump-license php-src,openssl,curl');
+ $this->output->writeln(' - --for-extensions: dump-license --for-extensions=openssl,mbstring');
+ $this->output->writeln(' - --for-libs: dump-license --for-libs=openssl,zlib');
+ $this->output->writeln(' - --for-packages: dump-license --for-packages=php,libssl');
+ return self::FAILURE;
+ }
+
+ // Deduplicate artifacts
+ $artifacts_to_dump = array_values(array_unique($artifacts_to_dump));
+
+ logger()->info('Dumping licenses for ' . count($artifacts_to_dump) . ' artifact(s)');
+ logger()->debug('Artifacts: ' . implode(', ', $artifacts_to_dump));
+
+ // Add artifacts to dumper
+ $dumper->addArtifacts($artifacts_to_dump);
+
+ // Dump
+ $success = $dumper->dump($dump_dir);
+
+ if ($success) {
+ InteractiveTerm::success('Licenses dumped successfully: ' . $dump_dir);
+ // $this->output->writeln("✓ Successfully dumped licenses to: {$dump_dir}");
+ // $this->output->writeln(" Total artifacts: " . count($artifacts_to_dump) . '');
+ return self::SUCCESS;
+ }
+
+ $this->output->writeln('Failed to dump licenses');
+ return self::FAILURE;
+ }
+
+ /**
+ * Resolve artifacts from extension names.
+ *
+ * @param array $extensions Extension names
+ * @return array Artifact names
+ */
+ private function resolveFromExtensions(array $extensions): array
+ {
+ // Convert extension names to package names
+ $packages = array_map(fn ($ext) => "ext-{$ext}", $extensions);
+
+ // Automatically include php-related artifacts
+ array_unshift($packages, 'php');
+ array_unshift($packages, 'php-micro');
+ array_unshift($packages, 'php-embed');
+ array_unshift($packages, 'php-fpm');
+
+ return $this->resolveFromPackages($packages);
+ }
+
+ /**
+ * Resolve artifacts from package names.
+ *
+ * @param array $packages Package names
+ * @return array Artifact names
+ */
+ private function resolveFromPackages(array $packages): array
+ {
+ $artifacts = [];
+ $include_suggests = !$this->getOption('without-suggests');
+
+ // Resolve package dependencies
+ $resolved_packages = DependencyResolver::resolve($packages, [], $include_suggests);
+
+ foreach ($resolved_packages as $pkg_name) {
+ try {
+ $pkg = PackageLoader::getPackage($pkg_name);
+ if ($artifact = $pkg->getArtifact()) {
+ $artifacts[] = $artifact->getName();
+ }
+ } catch (\Throwable $e) {
+ logger()->debug("Package {$pkg_name} has no artifact or failed to load: {$e->getMessage()}");
+ }
+ }
+
+ return array_unique($artifacts);
+ }
+}
diff --git a/src/StaticPHP/ConsoleApplication.php b/src/StaticPHP/ConsoleApplication.php
index 9e37698c..bd2e4eb1 100644
--- a/src/StaticPHP/ConsoleApplication.php
+++ b/src/StaticPHP/ConsoleApplication.php
@@ -12,6 +12,7 @@ use StaticPHP\Command\Dev\ShellCommand;
use StaticPHP\Command\Dev\SortConfigCommand;
use StaticPHP\Command\DoctorCommand;
use StaticPHP\Command\DownloadCommand;
+use StaticPHP\Command\DumpLicenseCommand;
use StaticPHP\Command\ExtractCommand;
use StaticPHP\Command\InstallPackageCommand;
use StaticPHP\Command\SPCConfigCommand;
@@ -55,6 +56,7 @@ class ConsoleApplication extends Application
new BuildLibsCommand(),
new ExtractCommand(),
new SPCConfigCommand(),
+ new DumpLicenseCommand(),
// dev commands
new ShellCommand(),
diff --git a/src/StaticPHP/Util/LicenseDumper.php b/src/StaticPHP/Util/LicenseDumper.php
new file mode 100644
index 00000000..57fc3aec
--- /dev/null
+++ b/src/StaticPHP/Util/LicenseDumper.php
@@ -0,0 +1,262 @@
+ Artifact names to dump */
+ private array $artifacts = [];
+
+ /**
+ * Add artifacts by name.
+ *
+ * @param array $artifacts Artifact names
+ */
+ public function addArtifacts(array $artifacts): self
+ {
+ $this->artifacts = array_unique(array_merge($this->artifacts, $artifacts));
+ return $this;
+ }
+
+ /**
+ * Dump all collected artifact licenses to target directory.
+ *
+ * @param string $target_dir Target directory path
+ * @return bool True on success
+ */
+ public function dump(string $target_dir): bool
+ {
+ // Create target directory if not exists (don't clean existing files)
+ if (!is_dir($target_dir)) {
+ FileSystem::createDir($target_dir);
+ } else {
+ logger()->debug("Target directory exists, will append/update licenses: {$target_dir}");
+ }
+
+ $license_summary = [];
+ $dumped_count = 0;
+
+ foreach ($this->artifacts as $artifact_name) {
+ $artifact = ArtifactLoader::getArtifactInstance($artifact_name);
+ if ($artifact === null) {
+ logger()->warning("Artifact not found, skipping: {$artifact_name}");
+ continue;
+ }
+
+ try {
+ $result = $this->dumpArtifactLicense($artifact, $target_dir, $license_summary);
+ if ($result) {
+ ++$dumped_count;
+ }
+ } catch (\Throwable $e) {
+ logger()->warning("Failed to dump license for {$artifact_name}: {$e->getMessage()}");
+ }
+ }
+
+ // Generate LICENSE-SUMMARY.json (read-modify-write)
+ $this->generateSummary($target_dir, $license_summary);
+
+ logger()->info("Successfully dumped {$dumped_count} license(s) to: {$target_dir}");
+ return true;
+ }
+
+ /**
+ * Dump license for a single artifact.
+ *
+ * @param Artifact $artifact Artifact instance
+ * @param string $target_dir Target directory
+ * @param array &$license_summary Summary data to populate
+ * @return bool True if dumped
+ * @throws SPCInternalException
+ */
+ private function dumpArtifactLicense(Artifact $artifact, string $target_dir, array &$license_summary): bool
+ {
+ $artifact_name = $artifact->getName();
+
+ // Get metadata from ArtifactConfig
+ $artifact_config = ArtifactConfig::get($artifact_name);
+ $config = $artifact_config['metadata'] ?? null;
+
+ if ($config === null) {
+ logger()->debug("No metadata for artifact: {$artifact_name}");
+ return false;
+ }
+
+ $license_type = $config['license'] ?? null;
+ $license_files = $config['license-files'] ?? [];
+
+ // Ensure license_files is array
+ if (is_string($license_files)) {
+ $license_files = [$license_files];
+ }
+
+ if (empty($license_files)) {
+ logger()->debug("No license files specified for: {$artifact_name}");
+ return false;
+ }
+
+ // Record in summary
+ $summary_license = $license_type ?? 'Custom';
+ if (!isset($license_summary[$summary_license])) {
+ $license_summary[$summary_license] = [];
+ }
+ $license_summary[$summary_license][] = $artifact_name;
+
+ // Dump each license file
+ $file_count = count($license_files);
+ $dumped_any = false;
+
+ foreach ($license_files as $index => $license_file_path) {
+ // Construct output filename
+ if ($file_count === 1) {
+ $output_filename = "{$artifact_name}_LICENSE.txt";
+ } else {
+ $output_filename = "{$artifact_name}_LICENSE_{$index}.txt";
+ }
+
+ $output_path = "{$target_dir}/{$output_filename}";
+
+ // Skip if file already exists (avoid duplicate writes)
+ if (file_exists($output_path)) {
+ logger()->debug("License file already exists, skipping: {$output_filename}");
+ $dumped_any = true; // Still count as dumped
+ continue;
+ }
+
+ // Try to read license file from source directory
+ $license_content = $this->readLicenseFile($artifact, $license_file_path);
+ if ($license_content === null) {
+ logger()->warning("License file not found for {$artifact_name}: {$license_file_path}");
+ continue;
+ }
+
+ // Write to target
+ if (file_put_contents($output_path, $license_content) === false) {
+ throw new SPCInternalException("Failed to write license file: {$output_path}");
+ }
+
+ logger()->info("Dumped license: {$output_filename}");
+ $dumped_any = true;
+ }
+
+ return $dumped_any;
+ }
+
+ /**
+ * Read license file content from artifact's source directory.
+ *
+ * @param Artifact $artifact Artifact instance
+ * @param string $license_file_path Relative path to license file
+ * @return null|string License content, or null if not found
+ */
+ private function readLicenseFile(Artifact $artifact, string $license_file_path): ?string
+ {
+ $artifact_name = $artifact->getName();
+
+ // Try source directory first (if extracted)
+ if ($artifact->isSourceExtracted()) {
+ $source_dir = $artifact->getSourceDir();
+ $full_path = "{$source_dir}/{$license_file_path}";
+
+ logger()->debug("Checking license file: {$full_path}");
+ if (file_exists($full_path)) {
+ logger()->info("Reading license from source: {$full_path}");
+ return file_get_contents($full_path);
+ }
+ } else {
+ logger()->debug("Artifact source not extracted: {$artifact_name}");
+ }
+
+ // Fallback: try SOURCE_PATH directly
+ $fallback_path = SOURCE_PATH . "/{$artifact_name}/{$license_file_path}";
+ logger()->debug("Checking fallback path: {$fallback_path}");
+ if (file_exists($fallback_path)) {
+ logger()->info("Reading license from fallback path: {$fallback_path}");
+ return file_get_contents($fallback_path);
+ }
+
+ logger()->debug("License file not found in any location for {$artifact_name}");
+ return null;
+ }
+
+ /**
+ * Generate LICENSE-SUMMARY.json file with read-modify-write support.
+ *
+ * @param string $target_dir Target directory
+ * @param array $license_summary License summary data (license_type => [artifacts])
+ */
+ private function generateSummary(string $target_dir, array $license_summary): void
+ {
+ if (empty($license_summary)) {
+ logger()->debug('No licenses to summarize');
+ return;
+ }
+
+ $summary_file = "{$target_dir}/LICENSE-SUMMARY.json";
+
+ // Read existing summary if exists
+ $existing_data = [];
+ if (file_exists($summary_file)) {
+ $content = file_get_contents($summary_file);
+ $existing_data = json_decode($content, true) ?? [];
+ logger()->debug('Loaded existing LICENSE-SUMMARY.json');
+ }
+
+ // Initialize structure
+ if (!isset($existing_data['artifacts'])) {
+ $existing_data['artifacts'] = [];
+ }
+ if (!isset($existing_data['summary'])) {
+ $existing_data['summary'] = ['license_types' => []];
+ }
+
+ // Merge new license information
+ foreach ($license_summary as $license_type => $artifacts) {
+ foreach ($artifacts as $artifact_name) {
+ // Add/update artifact info
+ $existing_data['artifacts'][$artifact_name] = [
+ 'license' => $license_type,
+ 'dumped_at' => date('Y-m-d H:i:s'),
+ ];
+
+ // Update license_types summary
+ if (!isset($existing_data['summary']['license_types'][$license_type])) {
+ $existing_data['summary']['license_types'][$license_type] = [];
+ }
+ if (!in_array($artifact_name, $existing_data['summary']['license_types'][$license_type])) {
+ $existing_data['summary']['license_types'][$license_type][] = $artifact_name;
+ }
+ }
+ }
+
+ // Sort license types and artifacts
+ ksort($existing_data['summary']['license_types']);
+ foreach ($existing_data['summary']['license_types'] as &$artifacts) {
+ sort($artifacts);
+ }
+ ksort($existing_data['artifacts']);
+
+ // Update totals
+ $existing_data['summary']['total_artifacts'] = count($existing_data['artifacts']);
+ $existing_data['summary']['total_license_types'] = count($existing_data['summary']['license_types']);
+ $existing_data['summary']['last_updated'] = date('Y-m-d H:i:s');
+
+ // Write JSON file
+ file_put_contents(
+ $summary_file,
+ json_encode($existing_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
+ );
+
+ logger()->info('Generated LICENSE-SUMMARY.json with ' . $existing_data['summary']['total_artifacts'] . ' artifact(s)');
+ }
+}