mirror of
https://github.com/HKUDS/CLI-Anything.git
synced 2026-08-30 17:34:27 +08:00
Merge origin/main into feature/cli-matrix-multi-approach
This commit is contained in:
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user