Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions src/apm_cli/commands/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -448,5 +448,5 @@ def _create_minimal_apm_yml(config, plugin=False):
apm_yml_data["scripts"] = {}

# Write apm.yml
with open(APM_YML_FILENAME, "w") as f:
yaml.safe_dump(apm_yml_data, f, default_flow_style=False, sort_keys=False)
with open(APM_YML_FILENAME, "w", encoding="utf-8") as f:
yaml.safe_dump(apm_yml_data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
4 changes: 2 additions & 2 deletions src/apm_cli/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,8 @@ def _validate_and_add_packages_to_apm_yml(packages, dry_run=False, dev=False):

# Write back to apm.yml
try:
with open(apm_yml_path, "w") as f:
yaml.safe_dump(data, f, default_flow_style=False, sort_keys=False)
with open(apm_yml_path, "w", encoding="utf-8") as f:
yaml.safe_dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
_rich_success(f"Updated {APM_YML_FILENAME} with {len(validated_packages)} new package(s)")
except Exception as e:
_rich_error(f"Failed to write {APM_YML_FILENAME}: {e}")
Expand Down
4 changes: 2 additions & 2 deletions src/apm_cli/commands/uninstall/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ def uninstall(ctx, packages, dry_run):
_rich_info(f"Removed {package} from apm.yml")
data["dependencies"]["apm"] = current_deps
try:
with open(apm_yml_path, "w") as f:
yaml.safe_dump(data, f, default_flow_style=False, sort_keys=False)
with open(apm_yml_path, "w", encoding="utf-8") as f:
yaml.safe_dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
_rich_success(f"Updated {APM_YML_FILENAME} (removed {len(packages_to_remove)} package(s))")
except Exception as e:
_rich_error(f"Failed to write {APM_YML_FILENAME}: {e}")
Expand Down
8 changes: 4 additions & 4 deletions src/apm_cli/core/script_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -822,8 +822,8 @@ def _add_dependency_to_config(self, package_ref: str) -> None:
config["dependencies"]["apm"].append(package_ref)

# Write back to file
with open(config_path, "w") as f:
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(config, f, default_flow_style=False, sort_keys=False, allow_unicode=True)

print(f" [i] Added {package_ref} to apm.yml dependencies")

Expand All @@ -838,8 +838,8 @@ def _create_minimal_config(self) -> None:
"description": "Auto-generated for zero-config virtual package execution",
}

with open("apm.yml", "w") as f:
yaml.dump(minimal_config, f, default_flow_style=False, sort_keys=False)
with open("apm.yml", "w", encoding="utf-8") as f:
yaml.dump(minimal_config, f, default_flow_style=False, sort_keys=False, allow_unicode=True)

print(f" [i] Created minimal apm.yml for zero-config execution")

Expand Down
8 changes: 4 additions & 4 deletions src/apm_cli/deps/github_downloader.py
Original file line number Diff line number Diff line change
Expand Up @@ -1204,7 +1204,7 @@ def download_virtual_file_package(self, dep_ref: DependencyReference, target_pat

# Create target directory structure
target_path.mkdir(parents=True, exist_ok=True)

# Determine the subdirectory based on file extension
subdirs = {
'.prompt.md': 'prompts',
Expand Down Expand Up @@ -1649,7 +1649,7 @@ def download_subdirectory_package(self, dep_ref: DependencyReference, target_pat
_data = _yaml.safe_load(_f) or {}
_data["version"] = short_sha
with open(apm_yml_path, "w", encoding="utf-8") as _f:
_yaml.dump(_data, _f, default_flow_style=False, sort_keys=False)
_yaml.dump(_data, _f, default_flow_style=False, sort_keys=False, allow_unicode=True)

# Update progress - complete
if progress_obj and progress_task_id is not None:
Expand Down Expand Up @@ -1950,7 +1950,7 @@ def download_package(
_data = _yaml.safe_load(_f) or {}
_data["version"] = short_sha
with open(apm_yml_path, "w", encoding="utf-8") as _f:
_yaml.dump(_data, _f, default_flow_style=False, sort_keys=False)
_yaml.dump(_data, _f, default_flow_style=False, sort_keys=False, allow_unicode=True)

# Create and return PackageInfo
return PackageInfo(
Expand All @@ -1976,4 +1976,4 @@ def progress_callback(op_code, cur_count, max_count=None, message=''):
else:
print(f"\r Cloning: {message} ({cur_count})", end='', flush=True)

return progress_callback
return progress_callback
32 changes: 32 additions & 0 deletions tests/unit/test_init_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,38 @@ def test_init_auto_detection(self):
finally:
os.chdir(self.original_dir) # restore CWD before TemporaryDirectory cleanup


def test_init_unicode_author(self):
"""Test that non-ASCII author names are written as UTF-8, not escaped."""
with tempfile.TemporaryDirectory() as tmp_dir:
os.chdir(tmp_dir)
try:

import subprocess

subprocess.run(["git", "init"], capture_output=True, check=True)
subprocess.run(
["git", "config", "user.name", "Pepe Rodríguez"],
capture_output=True,
check=True,
)

result = self.runner.invoke(cli, ["init", "--yes"])

assert result.exit_code == 0

# Verify parsed value
with open("apm.yml", encoding="utf-8") as f:
config = yaml.safe_load(f)
assert config["author"] == "Pepe Rodríguez"

# Verify raw file contains actual UTF-8, not escaped sequences
raw = Path("apm.yml").read_text(encoding="utf-8")
assert "Rodríguez" in raw
assert "\\x" not in raw
finally:
os.chdir(self.original_dir) # restore CWD before TemporaryDirectory cleanup

def test_init_does_not_create_skill_md(self):
"""Test that init does not create SKILL.md (only apm.yml)."""
with tempfile.TemporaryDirectory() as tmp_dir:
Expand Down
50 changes: 50 additions & 0 deletions tests/unit/test_install_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,3 +243,53 @@ def test_install_dry_run_with_no_apm_yml_shows_what_would_be_created(
assert "Would add" in result.output or "Dry run" in result.output
# apm.yml should still be created (for dry-run to work)
assert Path("apm.yml").exists()

@patch("apm_cli.commands.install._validate_package_exists")
@patch("apm_cli.commands.install.APM_DEPS_AVAILABLE", True)
@patch("apm_cli.commands.install.APMPackage")
@patch("apm_cli.commands.install._install_apm_dependencies")
def test_install_preserves_unicode_author_on_rewrite(
self, mock_install_apm, mock_apm_package, mock_validate
):
"""Test that install round-trip preserves non-ASCII author as UTF-8."""
with self._chdir_tmp():
# Create apm.yml with non-ASCII author
initial_config = {
"name": "test-project",
"version": "0.1.0",
"author": "Alejandro López Sánchez",
"dependencies": {"apm": []},
}
with open("apm.yml", "w", encoding="utf-8") as f:
yaml.safe_dump(
initial_config, f, default_flow_style=False,
sort_keys=False, allow_unicode=True,
)

mock_validate.return_value = True

mock_pkg_instance = MagicMock()
mock_pkg_instance.get_apm_dependencies.return_value = [
MagicMock(repo_url="test/package", reference="main")
]
mock_pkg_instance.get_mcp_dependencies.return_value = []
mock_apm_package.from_apm_yml.return_value = mock_pkg_instance

mock_install_apm.return_value = InstallResult(
diagnostics=MagicMock(
has_diagnostics=False, has_critical_security=False
)
)

result = self.runner.invoke(cli, ["install", "test/package"])
assert result.exit_code == 0

# Verify parsed value preserves Unicode
with open("apm.yml", encoding="utf-8") as f:
config = yaml.safe_load(f)
assert config["author"] == "Alejandro López Sánchez"

# Verify raw file contains actual UTF-8, not escaped sequences
raw = Path("apm.yml").read_text(encoding="utf-8")
assert "López" in raw
assert "\\x" not in raw
Loading