Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e94c5e1
Renamed target to archive in the positional arguments, options struct…
thomaslaurenson Oct 15, 2026
63dfe09
Stripped a trailing carriage return from each path read from stdin
thomaslaurenson Oct 15, 2026
485fada
Moved add's per-input loop into the core for create to share
thomaslaurenson Oct 15, 2026
8c34c51
Dropped the -p short flag from info, list and verify
thomaslaurenson Oct 15, 2026
8bf4180
Renamed create --version to --format-version
thomaslaurenson Oct 15, 2026
89e4333
Rejected an unknown --locale in extract and read
thomaslaurenson Oct 15, 2026
bf36718
Reversed read to take the archive first
thomaslaurenson Oct 15, 2026
2de4276
Made extract take files as positionals or stdin, replacing -f and --k…
thomaslaurenson Oct 15, 2026
dcd1392
Made create take the archive first and its files as positionals or stdin
thomaslaurenson Oct 15, 2026
788fe48
Bumped the version to 0.13.0 and drafted its changelog entry
thomaslaurenson Oct 15, 2026
3ec62d3
Stopped add and create from storing an archive inside itself
thomaslaurenson Oct 15, 2026
5a71582
Made create leave no archive behind when any file fails to add
thomaslaurenson Oct 15, 2026
af2d73c
Gave the new tests an archive of their own instead of one another tes…
thomaslaurenson Oct 15, 2026
3f04dc6
Checked the old-form error messages and added tests for path prefixes…
thomaslaurenson Oct 15, 2026
e5e25b4
Replaced target in the command descriptions and updated the create st…
thomaslaurenson Oct 15, 2026
2c72106
Shortened the flat changelog entry and updated the path and locale he…
thomaslaurenson Oct 15, 2026
8bebb9b
Added -e as the short form of --locale on every subcommand that takes…
thomaslaurenson Oct 15, 2026
ed51d43
Added -j, -q and -x as short forms of --flat, --property and --print
thomaslaurenson Oct 15, 2026
4ee2332
Made create fail without an archive when two inputs hold a file with …
thomaslaurenson Oct 15, 2026
8908e6e
Updated README container download stats
thomaslaurenson Oct 15, 2026
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
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,36 @@
# Changelog

## 0.13.0 - 2026-10-16

### Added

- Accept several files, or a list read from stdin, in create and extract
- Add a flat option to extract
- Add short flags -e, -j, -q and -x for the locale, flat, property and print options

### Changed

- Take the archive as the first argument in every subcommand, including create and read
- Keep folders when extracting named files
- Rename the create version option to format-version
- Reject an unknown locale in extract and read instead of using the default locale

### Fixed

- Show the encryption keys of encrypted files listed without their names
- Accept Windows line endings in paths read from stdin
- Stop add and create from storing an archive inside itself
- Leave no archive behind when create cannot add every file

### Removed

- Remove create -o, extract -f and -k, and the -p flag from info, list and verify

### Thanks

- Thanks to @sjoblomj for the contributions in this release
- Thanks to @MikaMika for reporting the inconsistent subcommand arguments

## 0.12.0 - 2026-10-04

### Added
Expand Down
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
cmake_minimum_required(VERSION 3.25)

project(mpqcli VERSION 0.12.0)
project(mpqcli VERSION 0.13.0)

# An option() name is global to the CMake cache, including a consumer's, so these
# carry the project prefix; a bare BUILD_STATIC claims a name anyone might want.
Expand Down
17 changes: 11 additions & 6 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -162,16 +162,21 @@ gen_docs_changelog: ## Copy CHANGELOG.md into the docs site
@cp CHANGELOG.md docs/changelog.md
@echo "[*] Updated docs/changelog.md"

# The exact count is read from the title attribute (<h3 title="1223">1.22K</h3>)
# and abbreviated to match the shields.io release downloads badge (1.2k)
.PHONY: gen_downloads
gen_downloads: ## Update the package downloads badge in README.md from GitHub
@DOWNLOADS=$$(curl -s "$(PACKAGE_URL)" \
@DOWNLOADS=$$(curl -fs "$(PACKAGE_URL)" \
| grep -A2 "Total downloads" \
| grep -o '<h3 title="[0-9]*">[0-9]*</h3>' \
| grep -o 'title="[0-9]*"' \
| grep -o '[0-9]*' \
| sed -n 's/.*<h3 title="\([0-9][0-9]*\)".*/\1/p' \
| head -1); \
sed -i "s/package_downloads-[0-9]*-green/package_downloads-$$DOWNLOADS-green/" $(README); \
echo "[*] Updated package downloads badge: $$DOWNLOADS"
if [[ -z "$$DOWNLOADS" ]]; then \
printf 'gen_downloads: no download count found at %s\n' "$(PACKAGE_URL)" >&2; \
exit 1; \
fi; \
BADGE=$$(awk -v n="$$DOWNLOADS" 'BEGIN { if (n < 1000) print n; else printf "%.1fk\n", n / 1000 }'); \
sed -i "s/package_downloads-[^-]*-green/package_downloads-$$BADGE-green/" $(README); \
echo "[*] Updated package downloads badge: $$BADGE ($$DOWNLOADS)"

##@ GET

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

![Release Build](https://img.shields.io/github/actions/workflow/status/thegraydot/mpqcli/tag.yml?style=flat&label=release&logo=github) ![Main Build](https://img.shields.io/github/actions/workflow/status/thegraydot/mpqcli/main.yml?style=flat&label=main&logo=github)

![Release Version](https://img.shields.io/github/v/release/thegraydot/mpqcli?style=flat&logo=github) ![Release downloads](https://img.shields.io/github/downloads/thegraydot/mpqcli/total?style=flat&label=downloads&logo=github) ![Package downloads](https://img.shields.io/badge/package_downloads-996-green?style=flat&logo=github)
![Release Version](https://img.shields.io/github/v/release/thegraydot/mpqcli?style=flat&logo=github) ![Release downloads](https://img.shields.io/github/downloads/thegraydot/mpqcli/total?style=flat&label=downloads&logo=github) ![Package downloads](https://img.shields.io/badge/package_downloads-1.2k-green?style=flat&logo=github)

![C++ Version](https://img.shields.io/badge/Version-17-blue?style=flat&logo=cplusplus)

Expand Down
6 changes: 3 additions & 3 deletions app/add.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,21 @@ void RegisterAdd(CLI::App &app, Context &context) {
auto options = std::make_shared<Options>();
auto *sub = app.add_subcommand("add", "Add files to an existing MPQ archive");

sub->add_option("archive", options->add.archive, "Target MPQ archive")
sub->add_option("archive", options->add.archive, "MPQ archive")
->required()
->check(CLI::ExistingFile);
sub->add_option("files", options->files,
"Files or directories to add; pass - to read paths from stdin")
->required()
->expected(-1);
sub->add_option("-p,--path", options->add.path,
"Archive path for a single file, or prefix for a directory");
"Archive path for a single file, or prefix for a directory or several inputs");
CLI::Option *overwrite = sub->add_flag("-w,--overwrite", options->add.overwrite,
"Replace every file that already exists in the archive");
sub->add_flag("-u,--update", options->add.update,
"Replace only files that changed. Compares size, then timestamp/MD5/CRC32")
->excludes(overwrite);
sub->add_option("--locale", options->add.locale, "Locale to use for added file")
sub->add_option("-e,--locale", options->add.locale, "Locale to use for added file")
->check(locale_valid);
sub->add_option("-g,--game", options->add.game_profile,
"Game profile for compression rules. Valid options:\n" +
Expand Down
2 changes: 1 addition & 1 deletion app/compact.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ void RegisterCompact(CLI::App &app, Context &context) {
auto options = std::make_shared<mpqcli::CompactOptions>();
auto *sub = app.add_subcommand("compact", "Compact the MPQ archive");

sub->add_option("target", options->target, "Target MPQ archive")
sub->add_option("archive", options->archive, "MPQ archive")
->required()
->check(CLI::ExistingFile);
sub->add_option("-l,--listfile", options->listfile, "File listing content of an MPQ archive")
Expand Down
31 changes: 21 additions & 10 deletions app/create.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,40 +3,49 @@
#include <cstdint>
#include <memory>
#include <optional>
#include <string>
#include <vector>

#include <CLI/CLI.hpp>
#include <StormLib.h>

#include "commands.h"
#include "gamerules/rules.h"
#include "util/path.h"
#include "validators.h"

void RegisterCreate(CLI::App &app, Context &context) {
struct Options {
mpqcli::CreateOptions create;
// Local paths, or - for the lines on stdin, settled into create.files by the callback
std::vector<std::string> files;
// The documented numbering is 1 to 4; StormLib counts the same formats from 0
std::optional<int32_t> version;
std::optional<int32_t> format_version;
};

auto options = std::make_shared<Options>();
auto *sub = app.add_subcommand("create", "Create an MPQ archive from target file or directory");
auto *sub = app.add_subcommand("create", "Create an MPQ archive from files and directories");

sub->add_option("target", options->create.target, "Directory or file to put in MPQ archive")
sub->add_option("archive", options->create.archive, "MPQ archive to create")
->required()
->check(CLI::ExistingPath);
->check(CLI::NonexistentPath);
sub->add_option("files", options->files,
"Files or directories to add; pass - to read paths from stdin")
->required()
->expected(-1);
sub->add_option("-p,--path", options->create.path,
"Archive path for a single file, or prefix for a directory");
sub->add_option("-o,--output", options->create.output, "Output MPQ archive");
"Archive path for a single file, or prefix for a directory or several inputs");
sub->add_flag("-s,--sign", options->create.sign, "Sign the MPQ archive (default false)");
sub->add_option("--locale", options->create.locale, "Locale to use for added files")
sub->add_option("-e,--locale", options->create.locale, "Locale to use for added files")
->check(locale_valid);
sub->add_option("-g,--game", options->create.game_profile,
"Game profile for MPQ creation. Valid options:\n" +
mpqcli::GameRules::GetAvailableProfiles())
->check(game_profile_valid);

mpqcli::MpqCreateSettingsOverrides &create = options->create.create_overrides;
sub->add_option("--version", options->version, "Override the MPQ archive version")
sub->add_option("--format-version", options->format_version,
"Override the MPQ archive format version")
->check(CLI::Range(1, 4))
->group("Game setting overrides");
sub->add_option("--stream-flags", create.stream_flags, "Override stream flags")
Expand Down Expand Up @@ -66,9 +75,11 @@ void RegisterCreate(CLI::App &app, Context &context) {
->group("Game setting overrides");

sub->callback([options, &context]() {
if (options->version.has_value()) {
const auto files = mpqcli::ExpandStdinMarker(options->files, context.in);
options->create.files.assign(files.begin(), files.end());
if (options->format_version.has_value()) {
options->create.create_overrides.mpq_version =
static_cast<DWORD>(options->version.value() - 1);
static_cast<DWORD>(options->format_version.value() - 1);
}
context.exit_code = mpqcli::Create(options->create, context.err, context.cancelled) ? 0 : 1;
});
Expand Down
43 changes: 33 additions & 10 deletions app/extract.cpp
Original file line number Diff line number Diff line change
@@ -1,27 +1,50 @@
#include "commands/extract.h"

#include <memory>
#include <string>
#include <vector>

#include <CLI/CLI.hpp>

#include "commands.h"
#include "util/path.h"
#include "validators.h"

void RegisterExtract(CLI::App &app, Context &context) {
auto options = std::make_shared<mpqcli::ExtractOptions>();
struct Options {
mpqcli::ExtractOptions extract;
// Names inside the archive, or - for the lines on stdin, settled into extract.files
// by the callback
std::vector<std::string> files;
};

auto options = std::make_shared<Options>();
auto *sub = app.add_subcommand("extract", "Extract files from the MPQ archive");

sub->add_option("target", options->target, "Target MPQ archive")
sub->add_option("archive", options->extract.archive, "MPQ archive")
->required()
->check(CLI::ExistingFile);
sub->add_option("-o,--output", options->output, "Output directory");
sub->add_option("-f,--file", options->file, "Target file to extract");
sub->add_flag("-k,--keep", options->keep_folder_structure,
"Keep folder structure (default false)");
sub->add_option("-l,--listfile", options->listfile, "File listing content of an MPQ archive")
->check(CLI::ExistingFile);
sub->add_option("--locale", options->locale, "Preferred locale for extracted file");
CLI::Option *files =
sub->add_option("files", options->files,
"Files to extract, or every file when omitted; pass - to read "
"names from stdin");
sub->add_option("-o,--output", options->extract.output, "Output directory");
sub->add_flag("-j,--flat", options->extract.flat,
"Write files into the output directory without their folders");
sub->add_option("-l,--listfile", options->extract.listfile,
"File listing content of an MPQ archive, when extracting every file")
->check(CLI::ExistingFile)
->excludes(files);
sub->add_option("-e,--locale", options->extract.locale, "Preferred locale for extracted files")
->check(locale_valid);

sub->callback([options, &context]() {
context.exit_code = mpqcli::Extract(*options, context.err, context.cancelled) ? 0 : 1;
// An empty stdin extracts nothing rather than everything, so a pipeline whose
// filter matched no names cannot unpack the whole archive
if (!options->files.empty()) {
options->extract.files = mpqcli::ExpandStdinMarker(options->files, context.in);
}
context.exit_code =
mpqcli::Extract(options->extract, context.err, context.cancelled) ? 0 : 1;
});
}
4 changes: 2 additions & 2 deletions app/info.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ void RegisterInfo(CLI::App &app, Context &context) {
auto options = std::make_shared<mpqcli::InfoOptions>();
auto *sub = app.add_subcommand("info", "Prints info about an MPQ archive");

sub->add_option("target", options->target, "Target MPQ archive")
sub->add_option("archive", options->archive, "MPQ archive")
->required()
->check(CLI::ExistingFile);
sub->add_option("-p,--property", options->property, "Prints only a specific property value")
sub->add_option("-q,--property", options->property, "Prints only a specific property value")
->check(CLI::IsMember(valid_properties));

sub->callback([options, &context]() { mpqcli::Info(*options, context.out); });
Expand Down
4 changes: 2 additions & 2 deletions app/list.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,15 @@ void RegisterList(CLI::App &app, Context &context) {
auto options = std::make_shared<mpqcli::ListOptions>();
auto *sub = app.add_subcommand("list", "List files from the MPQ archive");

sub->add_option("target", options->target, "Target MPQ archive")
sub->add_option("archive", options->archive, "MPQ archive")
->required()
->check(CLI::ExistingFile);
sub->add_option("-l,--listfile", options->listfile, "File listing content of an MPQ archive")
->check(CLI::ExistingFile);
sub->add_flag("-d,--detailed", options->detailed,
"File listing with additional columns (default false)");
sub->add_flag("-a,--all", options->all, "File listing including hidden files (default false)");
sub->add_option("-p,--property", options->properties, "Prints only specific property values")
sub->add_option("-q,--property", options->properties, "Prints only specific property values")
->check(CLI::IsMember(valid_properties));

sub->callback([options, &context]() { mpqcli::List(*options, context.out, context.err); });
Expand Down
8 changes: 5 additions & 3 deletions app/read.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,18 @@
#include <CLI/CLI.hpp>

#include "commands.h"
#include "validators.h"

void RegisterRead(CLI::App &app, Context &context) {
auto options = std::make_shared<mpqcli::ReadOptions>();
auto *sub = app.add_subcommand("read", "Read a file from an MPQ archive");

sub->add_option("file", options->file, "File to read")->required();
sub->add_option("target", options->target, "Target MPQ archive")
sub->add_option("archive", options->archive, "MPQ archive")
->required()
->check(CLI::ExistingFile);
sub->add_option("--locale", options->locale, "Preferred locale for read file");
sub->add_option("file", options->file, "File to read")->required();
sub->add_option("-e,--locale", options->locale, "Preferred locale for read file")
->check(locale_valid);

sub->callback([options, &context]() {
context.exit_code = mpqcli::Read(*options, context.out, context.err) ? 0 : 1;
Expand Down
5 changes: 3 additions & 2 deletions app/remove.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ void RegisterRemove(CLI::App &app, Context &context) {
auto options = std::make_shared<mpqcli::RemoveOptions>();
auto *sub = app.add_subcommand("remove", "Remove files from an existing MPQ archive");

sub->add_option("archive", options->archive, "Target MPQ archive")
sub->add_option("archive", options->archive, "MPQ archive")
->required()
->check(CLI::ExistingFile);
sub->add_option("files", options->files,
"Archive paths of files to remove; pass - to read paths from stdin")
->required()
->expected(-1);
sub->add_option("--locale", options->locale, "Locale of file to remove")->check(locale_valid);
sub->add_option("-e,--locale", options->locale, "Locale of file to remove")
->check(locale_valid);

sub->callback([options, &context]() {
options->files = mpqcli::ExpandStdinMarker(options->files, context.in);
Expand Down
5 changes: 3 additions & 2 deletions app/rename.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@ void RegisterRename(CLI::App &app, Context &context) {
auto options = std::make_shared<mpqcli::RenameOptions>();
auto *sub = app.add_subcommand("rename", "Rename a file in an existing MPQ archive");

sub->add_option("archive", options->archive, "Target MPQ archive")
sub->add_option("archive", options->archive, "MPQ archive")
->required()
->check(CLI::ExistingFile);
sub->add_option("old-file", options->old_file, "Current archive path of the file")->required();
sub->add_option("new-file", options->new_file, "New archive path of the file")->required();
sub->add_option("--locale", options->locale, "Locale of file to rename")->check(locale_valid);
sub->add_option("-e,--locale", options->locale, "Locale of file to rename")
->check(locale_valid);

sub->callback([options, &context]() {
context.exit_code = mpqcli::Rename(*options, context.err) ? 0 : 1;
Expand Down
4 changes: 2 additions & 2 deletions app/verify.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ void RegisterVerify(CLI::App &app, Context &context) {
auto options = std::make_shared<mpqcli::VerifyOptions>();
auto *sub = app.add_subcommand("verify", "Verify the MPQ archive");

sub->add_option("target", options->target, "Target MPQ archive")
sub->add_option("archive", options->archive, "MPQ archive")
->required()
->check(CLI::ExistingFile);
sub->add_flag("-p,--print", options->print_signature, "Print the digital signature (in hex)");
sub->add_flag("-x,--print", options->print_signature, "Print the digital signature (in hex)");

sub->callback([options, &context]() {
context.exit_code = mpqcli::Verify(*options, context.out, context.err) ? 0 : 1;
Expand Down
Loading