diff --git a/.github/scripts/pr-labeler.js b/.github/scripts/pr-labeler.js index 25aa62950..bda2757c0 100644 --- a/.github/scripts/pr-labeler.js +++ b/.github/scripts/pr-labeler.js @@ -52,10 +52,15 @@ function isDocumentationFile(path) { /^README(?:_[A-Z]+)?\.md$/.test(path) || /^(CONTRIBUTING|SECURITY)\.md$/.test(path) || /^docs\//.test(path) || + /^[^/]+\/agent-harness\/.*\.md$/i.test(path) || /^[^/]+\.md$/i.test(path) ); } +function isHarnessImplementationFile(path) { + return isHarnessFile(path) && !isDocumentationFile(path); +} + function titleLooksLikeRegistryCli(title) { return /\b(add|introduce|new)\b/i.test(title) && /\b(cli|harness|registry)\b/i.test(title); } @@ -64,7 +69,7 @@ function computeScriptLabels(files, title) { const paths = files.map((file) => file.filename); const labelsToApply = new Set(); - const hasHarnessChange = paths.some(isHarnessFile); + const hasHarnessImplementationChange = paths.some(isHarnessImplementationFile); const hasNewHarness = files.some(isNewHarnessManifest); const registryOnly = paths.length > 0 && paths.every((path) => REGISTRY_FILES.has(path)); const registryNewCli = registryOnly && titleLooksLikeRegistryCli(title || ""); @@ -72,7 +77,7 @@ function computeScriptLabels(files, title) { if (hasNewHarness || registryNewCli) { labelsToApply.add("new-cli"); - } else if (hasHarnessChange) { + } else if (hasHarnessImplementationChange) { labelsToApply.add("existing-cli-fix"); } diff --git a/.github/scripts/tests/pr-labeler.test.js b/.github/scripts/tests/pr-labeler.test.js index 887e7d863..6c48a4b21 100644 --- a/.github/scripts/tests/pr-labeler.test.js +++ b/.github/scripts/tests/pr-labeler.test.js @@ -197,3 +197,12 @@ test("mixed README and harness changes are not documentation-only", () => { assert.deepStrictEqual(labels, ["existing-cli-fix"]); }); + +test("harness README-only changes are documentation", () => { + const labels = computeAllLabels({ + title: "docs(firefly-iii): clarify setup notes", + files: [{filename: "firefly-iii/agent-harness/README.md", status: "modified"}], + }); + + assert.deepStrictEqual(labels, ["documentation"]); +}); diff --git a/.github/scripts/tests/test_update_registry_dates.py b/.github/scripts/tests/test_update_registry_dates.py index 924c4a7dc..53b83fd45 100644 --- a/.github/scripts/tests/test_update_registry_dates.py +++ b/.github/scripts/tests/test_update_registry_dates.py @@ -60,3 +60,12 @@ def test_extract_pypi_package_supports_python_module_invocation(): install_cmd = "python -m pip install py4csr" assert MODULE._extract_pypi_package(install_cmd) == "py4csr" + + +def test_extract_pypi_package_skips_index_option_values(): + install_cmd = ( + "python -m pip install --index-url https://mirror.example/simple " + "--trusted-host mirror.example py4csr" + ) + + assert MODULE._extract_pypi_package(install_cmd) == "py4csr" diff --git a/.github/scripts/update_registry_dates.py b/.github/scripts/update_registry_dates.py index e7585a6e0..a2c72de60 100644 --- a/.github/scripts/update_registry_dates.py +++ b/.github/scripts/update_registry_dates.py @@ -19,6 +19,34 @@ USER_AGENT = "CLI-Anything registry date updater" GITHUB_REPO_RE = re.compile(r"https://github\.com/([^/]+/[^/#?]+?)(?:\.git)?(?:[/?#].*)?$") GIT_URL_RE = re.compile(r"https://github\.com/[^\s#]+") SUBDIRECTORY_RE = re.compile(r"#subdirectory=([^\s]+)") +PIP_OPTIONS_WITH_VALUES = { + "-c", + "--constraint", + "-r", + "--requirement", + "-i", + "--index-url", + "--extra-index-url", + "-f", + "--find-links", + "--trusted-host", + "--python-version", + "--platform", + "--implementation", + "--abi", + "--root", + "--prefix", + "--src", + "--target", + "--upgrade-strategy", + "-C", + "--config-settings", + "--cert", + "--client-cert", + "--cache-dir", + "--log", + "--report", +} def _fetch_json(url: str) -> dict | None: @@ -123,8 +151,15 @@ def _extract_pypi_package(install_cmd: str) -> str | None: if install_index is None or install_index >= len(tokens) or tokens[install_index] != "install": return None - for token in tokens[install_index + 1 :]: + index = install_index + 1 + while index < len(tokens): + token = tokens[index] if token.startswith("-"): + option_name = token.split("=", 1)[0] + if option_name in PIP_OPTIONS_WITH_VALUES and "=" not in token: + index += 2 + continue + index += 1 continue if "://" in token or token.startswith("git+"): return None diff --git a/live2d/agent-harness/cli_anything/live2d/tests/test_core.py b/live2d/agent-harness/cli_anything/live2d/tests/test_core.py index 5f0548e76..16cbbf97b 100644 --- a/live2d/agent-harness/cli_anything/live2d/tests/test_core.py +++ b/live2d/agent-harness/cli_anything/live2d/tests/test_core.py @@ -1,13 +1,17 @@ """Unit tests for Live2D CLI core modules (no backend needed).""" import json +import os +import time import pytest from pathlib import Path from unittest.mock import patch -from cli_anything.live2d.core.parser import load_model, ModelInfo, MotionRef, ExpressionRef +from cli_anything.live2d.core.parser import load_model, save_model, ModelInfo, MotionRef, ExpressionRef from cli_anything.live2d.core.validator import validate_model, ValidationResult from cli_anything.live2d.core.scanner import scan_directory, find_model_files +from cli_anything.live2d.core.backup import snapshot, auto_backup, list_backups, _backup_dir_for +from click.testing import CliRunner # ── Fixtures ──────────────────────────────────────────────────── @@ -179,3 +183,179 @@ class TestScanner: files = find_model_files(model_dir) assert len(files) == 1 assert files[0].suffix == ".json" + + +# ── Backup Tests ─────────────────────────────────────────────── + +class TestFlatten: + def test_flatten_copies_sound_assets(self, tmp_path): + """Regression: flatten must copy motion Sound files, not just reference them.""" + from cli_anything.live2d.live2d_cli import cli + + model_dir = tmp_path / "character" + model_dir.mkdir() + + # Create model with Sound reference + model_data = { + "Version": 3, + "FileReferences": { + "Moc": "model.moc3", + "Textures": ["textures/tex.png"], + "Motions": { + "idle": [ + { + "File": "motions/idle.motion3.json", + "FadeInTime": 0.5, + "FadeOutTime": 0.5, + "Sound": "sounds/effect.wav", + } + ] + }, + }, + } + model_file = model_dir / "test.model3.json" + model_file.write_text(json.dumps(model_data), encoding="utf-8") + + # Create referenced files + (model_dir / "model.moc3").touch() + (model_dir / "textures").mkdir() + (model_dir / "textures" / "tex.png").touch() + (model_dir / "motions").mkdir() + (model_dir / "motions" / "idle.motion3.json").write_text("{}", encoding="utf-8") + (model_dir / "sounds").mkdir() + sound_file = model_dir / "sounds" / "effect.wav" + sound_file.write_bytes(b"RIFF....WAVE") + + out_dir = tmp_path / "flat_output" + + runner = CliRunner() + result = runner.invoke(cli, ["flatten", str(model_file), "-o", str(out_dir)]) + + assert result.exit_code == 0, f"flatten failed: {result.output}" + + # Verify sound file was copied + assert (out_dir / "effect.wav").exists(), ( + f"Sound file 'effect.wav' was not copied to output dir. " + f"Contents: {list(out_dir.iterdir()) if out_dir.exists() else 'dir missing'}" + ) + + # Verify model JSON references flat sound path + flat_model = json.loads((out_dir / "test.model3.json").read_text(encoding="utf-8")) + flat_motion = flat_model["FileReferences"]["Motions"]["idle"][0] + assert flat_motion["Sound"] == "effect.wav", ( + f"Expected flat Sound path 'effect.wav', got '{flat_motion.get('Sound')}'" + ) + + +class TestBackup: + def test_backup_clean_json_mode_deletes_files(self, sample_model3_file): + """Regression: backup-clean --json must actually delete old backups, not just report.""" + from cli_anything.live2d.live2d_cli import cli + from cli_anything.live2d.core.backup import _backup_dir_for + + # Create 5 backups with distinct content + for i in range(5): + sample_model3_file.write_text( + json.dumps({"Version": i, "FileReferences": {}}), encoding="utf-8" + ) + snapshot(sample_model3_file) + + bdir = _backup_dir_for(sample_model3_file) + backups = sorted(bdir.glob("*.model3.json"), reverse=True) + assert len(backups) == 5 + + # Invoke backup-clean --keep 2 --json through the CLI + runner = CliRunner() + result = runner.invoke(cli, [ + "--json", "backup-clean", str(sample_model3_file), "--keep", "2" + ]) + + assert result.exit_code == 0, f"backup-clean failed: {result.output}" + + # Verify only 2 backups remain on disk + remaining = sorted(bdir.glob("*.model3.json"), reverse=True) + assert len(remaining) == 2, f"Expected 2 backups after cleanup, got {len(remaining)}" + + # Verify JSON output reports the deletion + out = json.loads(result.output) + assert out["delete"] == 3 + assert out["total"] == 5 + assert len(out["deleted"]) == 3 + + def test_auto_backup_skips_unchanged_content(self, sample_model3_file): + """Regression: auto_backup skips when content is unchanged (not just <1s old).""" + # First backup + result1 = auto_backup(sample_model3_file) + assert result1 is not None + + # Same content -> should skip + result2 = auto_backup(sample_model3_file) + assert result2 is None + + backups = list_backups(sample_model3_file) + assert len(backups) == 1 + + def test_auto_backup_saves_changed_content(self, sample_model3_file): + """Regression: auto_backup creates new backup when content changes.""" + result1 = auto_backup(sample_model3_file) + assert result1 is not None + + # Modify content + data = json.loads(sample_model3_file.read_text(encoding="utf-8")) + data["Version"] = 999 + sample_model3_file.write_text(json.dumps(data), encoding="utf-8") + + result2 = auto_backup(sample_model3_file) + assert result2 is not None + assert result2 != result1 + + backups = list_backups(sample_model3_file) + assert len(backups) == 2 + + def test_save_model_atomic(self, sample_model3_file): + """Regression: save_model writes atomically via temp file + os.replace.""" + info = load_model(sample_model3_file) + info.moc3 = "updated_model.moc3" + + # Track os.replace calls to verify atomic write + original_replace = os.replace + replace_calls = [] + + def tracking_replace(src, dst): + replace_calls.append((str(src), str(dst))) + # Verify the temp file exists before replace + assert Path(src).exists(), f"Temp file {src} should exist before replace" + return original_replace(src, dst) + + with patch("cli_anything.live2d.core.parser.os.replace", side_effect=tracking_replace): + save_model(info) + + # Verify os.replace was called (atomic write) + assert len(replace_calls) == 1, "save_model should call os.replace exactly once" + src, dst = replace_calls[0] + assert ".tmp" in src or ".model3.json" in src + + # Verify the file was actually updated + reloaded = load_model(sample_model3_file) + assert reloaded.moc3 == "updated_model.moc3" + + def test_save_model_cleanup_on_error(self, sample_model3_file): + """Regression: save_model cleans up temp file on error.""" + info = load_model(sample_model3_file) + info.moc3 = "should_not_persist.moc3" + + bdir = sample_model3_file.parent + tmp_files_before = set(bdir.glob(".*.tmp")) + + # Force an error during write + with patch("cli_anything.live2d.core.parser.os.fdopen", side_effect=OSError("disk full")): + with pytest.raises(OSError, match="disk full"): + save_model(info) + + tmp_files_after = set(bdir.glob(".*.tmp")) + new_tmps = tmp_files_after - tmp_files_before + assert len(new_tmps) == 0, f"Temp files not cleaned up: {new_tmps}" + + # Original file should be unchanged + reloaded = load_model(sample_model3_file) + assert reloaded.moc3 != "should_not_persist.moc3" diff --git a/registry.json b/registry.json index 1068eae4d..584178608 100644 --- a/registry.json +++ b/registry.json @@ -1437,6 +1437,25 @@ "url": "https://github.com/davidmyriel" } ] + }, + { + "name": "tinyfish", + "display_name": "TinyFish Web Agent", + "version": "0.1.1", + "description": "All four TinyFish products from the terminal: web search, clean page extraction, natural-language browser automation, and remote CDP browser sessions — via the REST APIs.", + "requires": "TinyFish API key (free at https://agent.tinyfish.ai/api-keys)", + "homepage": "https://tinyfish.ai", + "source_url": "https://github.com/webdevtodayjason/cli-anything-tinyfish", + "install_cmd": "pip install cli-anything-tinyfish", + "entry_point": "cli-anything-tinyfish", + "skill_md": "https://github.com/webdevtodayjason/cli-anything-tinyfish/blob/main/cli_anything/tinyfish/skills/SKILL.md", + "category": "web", + "contributors": [ + { + "name": "webdevtodayjason", + "url": "https://github.com/webdevtodayjason" + } + ] } ] }