commit bc7eac81511a361b7fedbf657e24ee2a55a3902a Author: wehub-resource-sync Date: Mon Jul 13 12:55:23 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..7424271 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "cli-anything", + "owner": { + "name": "cli-anything contributors" + }, + "metadata": { + "description": "Build powerful, stateful CLI interfaces for any GUI application using the cli-anything harness methodology." + }, + "plugins": [ + { + "name": "cli-anything", + "source": "./cli-anything-plugin", + "description": "Build powerful, stateful CLI interfaces for any GUI application using the cli-anything harness methodology.", + "author": { + "name": "cli-anything contributors" + }, + "category": "development" + } + ] +} diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..96c7786 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Default reviewers for every pull request. +* @yuh-yang @zhangxilong-43 @omerarslan0 diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..bedfe39 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,88 @@ +name: Bug Report +description: Report a bug in CLI-Anything +title: "[Bug]: " +labels: ["type: bug"] +body: + - type: markdown + attributes: + value: | + Thanks for reporting a bug! Please fill out the information below to help us reproduce and fix it. + + - type: input + id: software + attributes: + label: Affected Software/Harness + description: Which CLI harness is affected? (e.g., gimp, blender, inkscape, or "plugin" for the framework) + placeholder: e.g., gimp + validations: + required: true + + - type: input + id: version + attributes: + label: Version / Commit + description: The version or commit hash you are using. + placeholder: e.g., v1.0.0 or commit abc1234 + validations: + required: true + + - type: dropdown + id: os + attributes: + label: Operating System + options: + - Linux + - macOS + - Windows + - Other + validations: + required: true + + - type: input + id: python-version + attributes: + label: Python Version + placeholder: e.g., 3.10.12 + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to Reproduce + description: Provide clear steps to reproduce the bug. + placeholder: | + 1. Install the harness with `pip install -e .` + 2. Run `cli-anything-gimp project create test.xcf` + 3. See error... + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected Behavior + description: What did you expect to happen? + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual Behavior + description: What actually happened? Include error messages or tracebacks if applicable. + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Relevant Logs / Tracebacks + description: Paste any relevant output here. This will be auto-formatted as code. + render: shell + + - type: textarea + id: context + attributes: + label: Additional Context + description: Any other context, screenshots, or information that might help. diff --git a/.github/ISSUE_TEMPLATE/cli-wishlist.yml b/.github/ISSUE_TEMPLATE/cli-wishlist.yml new file mode 100644 index 0000000..4c9121f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/cli-wishlist.yml @@ -0,0 +1,55 @@ +name: "\U0001F31F CLI Wishlist" +description: "Suggest a software, codebase, or service you'd like CLI-Anything to support." +title: "[Wishlist] " +labels: ["wishlist"] +body: + - type: markdown + attributes: + value: | + ## Request a New CLI + + Is there a software, codebase, or service you wish had a CLI-Anything harness? Let us know! + + - type: dropdown + id: type + attributes: + label: Type + options: + - Desktop Software + - Web Service / SaaS + - Developer Tool / Codebase + - Other + validations: + required: true + + - type: input + id: software-name + attributes: + label: Software / Service Name + placeholder: "e.g., Godot, Ardour, FreeCAD, Darktable, etc." + validations: + required: true + + - type: input + id: software-link + attributes: + label: Link + placeholder: "e.g., https://www.darktable.org/" + validations: + required: true + + - type: textarea + id: use-case + attributes: + label: Brief use-case + placeholder: "e.g., Automate batch photo editing and RAW processing via command line..." + validations: + required: true + + - type: textarea + id: why + attributes: + label: Why should CLI-Anything support this? + placeholder: "e.g., Darktable has a large photography community and supports Lua scripting, making CLI integration feasible..." + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..f6a2ace --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: "\U0001F44B Contributor Sign-Up" + url: https://github.com/HKUDS/CLI-Anything/issues/new?template=contributor-signup.yml + about: Want to contribute a new CLI and gain reviewer/merge access? Sign up here! + - name: Questions & Discussions + url: https://github.com/HKUDS/CLI-Anything/discussions + about: Please ask questions and discuss ideas here instead of opening an issue. + - name: CLI Generation Help + url: https://github.com/HKUDS/CLI-Anything/discussions/categories/q-a + about: Need help generating a CLI for a new software? Ask in Discussions first. diff --git a/.github/ISSUE_TEMPLATE/contributor-signup.yml b/.github/ISSUE_TEMPLATE/contributor-signup.yml new file mode 100644 index 0000000..5d1fd32 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/contributor-signup.yml @@ -0,0 +1,63 @@ +name: "\U0001F44B Contributor Sign-Up" +description: "Sign up to contribute a new CLI harness and gain reviewer/merge access." +title: "[Contributor Sign-Up] " +labels: ["contributor-signup", "new-cli"] +body: + - type: markdown + attributes: + value: | + ## Welcome to CLI-Anything! + + New contributors are required to contribute a new CLI harness as their first contribution. Once your CLI is merged, you'll be granted access to review and merge PRs. + + Please read [CONTRIBUTING.md](https://github.com/HKUDS/CLI-Anything/blob/main/CONTRIBUTING.md) before starting. + + - type: textarea + id: experience + attributes: + label: Developing experience and past projects + placeholder: "e.g., 3 years of Python development, contributed to X and Y open-source projects..." + validations: + required: true + + - type: dropdown + id: contributing-read + attributes: + label: Have you read CONTRIBUTING.md? + options: + - "Yes" + - "Not yet, but I will before starting" + validations: + required: true + + - type: input + id: target-software + attributes: + label: Which GUI application will you build a CLI for? + placeholder: "e.g., Audacity, OBS Studio, FreeCAD, Krita, etc." + validations: + required: true + + - type: input + id: software-link + attributes: + label: Link to the software + placeholder: "e.g., https://www.audacityteam.org/" + validations: + required: true + + - type: textarea + id: use-case + attributes: + label: Brief use-case for the CLI + placeholder: "e.g., Automate batch audio processing and noise reduction via command line..." + validations: + required: true + + - type: checkboxes + id: agreement + attributes: + label: Agreement + options: + - label: "I agree to be assigned to PRs or issues related to my contribution." + required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..0aa8ebd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,52 @@ +name: Feature Request +description: Suggest a new feature or enhancement +title: "[Feature]: " +labels: ["type: enhancement"] +body: + - type: markdown + attributes: + value: | + Thanks for suggesting a feature! Please describe your idea clearly so we can evaluate it. + + - type: dropdown + id: category + attributes: + label: Category + description: What type of feature is this? + options: + - New Software CLI + - Enhancement to Existing Harness + - Plugin / Framework Improvement + - Documentation + - Other + validations: + required: true + + - type: textarea + id: problem + attributes: + label: Problem Statement + description: What problem does this feature solve? What pain point are you experiencing? + placeholder: I'm always frustrated when... + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed Solution + description: Describe the solution you'd like to see. + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives Considered + description: Have you considered any alternative solutions or workarounds? + + - type: textarea + id: context + attributes: + label: Additional Context + description: Any other context, mockups, or references that might help. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..d10f0d3 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,66 @@ +## Description + + + +Fixes # + +## Type of Change + + + +- [ ] **New Software CLI (in-repo)** — adds a CLI harness inside this monorepo +- [ ] **New Software CLI (standalone repo)** — registry-only PR pointing to an external repo +- [ ] **New Feature** — adds new functionality to an existing harness or the plugin +- [ ] **Bug Fix** — fixes incorrect behavior +- [ ] **Documentation** — updates docs only +- [ ] **Other** — please describe: + +--- + +### For New Software CLIs (in-repo) + + + +- [ ] `.md` SOP document exists at `/agent-harness/.md` +- [ ] Canonical `SKILL.md` exists at `skills/cli-anything-/SKILL.md` +- [ ] Packaged compatibility `SKILL.md` exists at `cli_anything//skills/SKILL.md` +- [ ] Unit tests at `cli_anything//tests/test_core.py` are present and pass without backend +- [ ] E2E tests at `cli_anything//tests/test_full_e2e.py` are present +- [ ] `README.md` includes the new software (with link to harness directory) +- [ ] `registry.json` includes an entry with `source_url: null` (see [Contributing guide](CONTRIBUTING.md#registry-fields)) +- [ ] `repl_skin.py` in `utils/` is an unmodified copy from the plugin + +### For New Software CLIs (standalone repo) + + + +- [ ] CLI is installable via `pip install ` or a `pip install git+https://...` URL +- [ ] `SKILL.md` exists in the external repo +- [ ] External repo has its own test suite +- [ ] `registry.json` entry includes `source_url` pointing to the external repo +- [ ] `registry.json` entry includes `skill_md` with full URL to the external SKILL.md +- [ ] `install_cmd` in `registry.json` works (tested locally) + +### For Existing CLI Modifications + + + +- [ ] All unit tests pass: `python3 -m pytest cli_anything//tests/test_core.py -v` +- [ ] All E2E tests pass: `python3 -m pytest cli_anything//tests/test_full_e2e.py -v` +- [ ] No test regressions — no previously passing tests were removed or weakened +- [ ] `registry.json` entry is updated if version, description, or requirements changed + +### General Checklist + +- [ ] Code follows existing patterns and conventions +- [ ] `--json` flag is supported on any new commands +- [ ] Commit messages follow the conventional format (`feat:`, `fix:`, `docs:`, `test:`) +- [ ] I have tested my changes locally + +## Test Results + + + +``` + +``` diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..0cbe6f0 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,25 @@ +cli-anything-hub: + - changed-files: + - any-glob-to-any-file: + - "cli-hub/**" + - "docs/hub/**" + - "registry.json" + - "public_registry.json" + - "matrix_registry.json" + +cli-anything-skill: + - changed-files: + - any-glob-to-any-file: + - "cli-anything-plugin/**" + - "codex-skill/**" + - "cli-hub-meta-skill/**" + - "openclaw-skill/**" + - "skills/**" + - "**/SKILL.md" + +github-actions: + - changed-files: + - any-glob-to-any-file: + - ".github/workflows/**" + - ".github/labeler.yml" + - ".github/scripts/pr-labeler.js" diff --git a/.github/scripts/generate_meta_skill.py b/.github/scripts/generate_meta_skill.py new file mode 100644 index 0000000..7759f1d --- /dev/null +++ b/.github/scripts/generate_meta_skill.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""Generate cli-hub-skill/SKILL.md from registry.json, public_registry.json, and matrix_registry.json.""" +import json +from pathlib import Path +from collections import defaultdict + +def main(): + repo_root = Path(__file__).parent.parent.parent + registry_path = repo_root / 'registry.json' + public_registry_path = repo_root / 'public_registry.json' + matrix_registry_path = repo_root / 'matrix_registry.json' + output_path = repo_root / 'cli-hub-skill' / 'SKILL.md' + + with open(registry_path) as f: + data = json.load(f) + + public_clis = [] + if public_registry_path.exists(): + with open(public_registry_path) as f: + public_data = json.load(f) + public_clis = public_data.get('clis', []) + + matrices = [] + if matrix_registry_path.exists(): + with open(matrix_registry_path) as f: + matrix_data = json.load(f) + matrices = matrix_data.get('matrices', []) + + total_count = len(data['clis']) + len(public_clis) + + # Group harness CLIs by category + by_category = defaultdict(list) + for cli in data['clis']: + by_category[cli['category']].append(cli) + + # Group public CLIs by category + public_by_category = defaultdict(list) + for cli in public_clis: + public_by_category[cli['category']].append(cli) + + lines = [ + "---", + "name: cli-anything-hub", + "description: >-", + f" Browse and install {total_count}+ CLI tools for GUI software and popular platforms.", + " Covers image editing, 3D, video, audio, office, diagrams, AI, communication, devops, and more.", + "---", + "", + "# CLI-Anything Hub", + "", + f"Agent-native CLI interfaces for {total_count} applications — {len(data['clis'])} harness CLIs (stateful, `--json`, REPL) plus {len(public_clis)} public/third-party CLIs (npm, uv, brew, and more).", + "", + "## Quick Install", + "", + "```bash", + "# First, install the CLI Hub package manager", + "pip install cli-anything-hub", + "", + "# Browse available CLIs", + "cli-hub list", + "", + "# Install any CLI by name", + "cli-hub install gimp", + "cli-hub install blender", + "cli-hub install generate-veo-video", + "", + "# Search by category or keyword", + "cli-hub search image", + "cli-hub search ai", + "", + "# Launch an installed CLI", + "cli-hub launch [args...]", + "```", + "", + "## CLI Matrices", + "", + f"`cli-hub` also ships {len(matrices)} curated cross-tool matrices: install one name to pull in a whole workflow kit and read its dedicated SKILL.md.", + "", + "```bash", + "# Browse curated matrices", + "cli-hub matrix list", + "", + "# Inspect one matrix", + "cli-hub matrix info video-creation", + "", + "# Check which providers are available locally", + "cli-hub matrix preflight video-creation --json", + "", + "# Install the whole matrix", + "cli-hub matrix install video-creation", + "```", + "", + "## CLI-Anything Harness CLIs", + "", + f"Stateful, agent-native wrappers for {len(data['clis'])} GUI applications. All support `--json` output, REPL mode, and undo/redo.", + "" + ] + + for category in sorted(by_category.keys()): + clis = by_category[category] + lines.append(f"### {category.title()}") + lines.append("") + lines.append("| Name | Description | Install |") + lines.append("|------|-------------|---------|") + + for cli in sorted(clis, key=lambda x: x['name']): + name = cli['display_name'] + desc = cli['description'] + install = f"`cli-hub install {cli['name']}`" + lines.append(f"| **{name}** | {desc} | {install} |") + + lines.append("") + + lines.extend([ + "## Public & Third-Party CLIs", + "", + f"Official and community CLIs for popular platforms, managed via npm, uv, brew, and other installers. {len(public_clis)} CLIs available.", + "" + ]) + + for category in sorted(public_by_category.keys()): + clis = public_by_category[category] + lines.append(f"### {category.title()}") + lines.append("") + lines.append("| Name | Description | Entry Point | Install | Skill |") + lines.append("|------|-------------|-------------|---------|-------|") + + for cli in sorted(clis, key=lambda x: x['name']): + name = cli['display_name'] + desc = cli['description'] + entry = f"`{cli['entry_point']}`" + install = f"`cli-hub install {cli['name']}`" + skill = cli.get('skill_md') or '—' + skill_cell = f"`{skill}`" if not str(skill).startswith("http") else skill + lines.append(f"| **{name}** | {desc} | {entry} | {install} | {skill_cell} |") + + lines.append("") + + if matrices: + lines.extend([ + "## Curated Matrices", + "", + "Each matrix is a curated multi-CLI workflow pulled from the CLI Matrix. Installing a matrix installs all member CLIs and points you at a matrix-specific SKILL.md.", + "", + "| Matrix | Description | CLIs | Install | Skill |", + "|--------|-------------|------|---------|-------|", + ]) + + for matrix in sorted(matrices, key=lambda x: x['name']): + skill = matrix.get('skill_md') or '—' + install = f"`cli-hub matrix install {matrix['name']}`" + lines.append( + f"| **{matrix['display_name']}** | {matrix['description']} | {len(matrix.get('clis', []))} | {install} | `{skill}` |" + ) + + lines.append("") + + lines.extend([ + "## How It Works", + "", + "`cli-hub` is a unified package manager for both harness CLIs and public CLIs:", + "", + "- **Harness CLIs**: installed via `pip` as `cli-anything-` packages", + "- **npm CLIs**: installed via `npm install -g`", + "- **uv CLIs**: installed via `uv tool install`", + "- **brew/script CLIs**: installed via the tool's native installer", + "- **bundled CLIs**: detected from PATH (pre-installed with the host app)", + "- **Matrices**: install a curated set of harness and public CLIs in one command", + "", + "## Harness CLI Usage Pattern", + "", + "All harness CLIs follow the same pattern:", + "", + "```bash", + "# Interactive REPL", + "cli-anything-", + "", + "# One-shot command", + "cli-anything- [options]", + "", + "# JSON output for agents", + "cli-anything- --json ", + "```", + "", + "## For AI Agents", + "", + "1. Install the hub: `pip install cli-anything-hub`", + "2. Install the CLI you need: `cli-hub install `", + "3. Run the CLI directly via its entry point, or use `cli-hub launch [args...]`", + "4. For harness CLIs: use `--json` flag for machine-readable output; check exit codes (0=success)", + "5. Read each harness CLI's full SKILL.md at the repo path shown in registry.json", + "", + "## More Info", + "", + f"- Repository: {data['meta']['repo']}", + "- Web Hub: https://clianything.cc", + f"- Last Updated: {data['meta']['updated']}", + ]) + + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text('\n'.join(lines) + '\n') + print( + f"Generated meta-skill with {len(data['clis'])} harness CLIs + " + f"{len(public_clis)} public CLIs + {len(matrices)} matrices at {output_path}" + ) + +if __name__ == '__main__': + main() diff --git a/.github/scripts/pr-labeler.js b/.github/scripts/pr-labeler.js new file mode 100644 index 0000000..bda2757 --- /dev/null +++ b/.github/scripts/pr-labeler.js @@ -0,0 +1,178 @@ +const LABELS = { + "new-cli": { + color: "0E8A16", + description: "Adds a new CLI or generated harness", + }, + "existing-cli-fix": { + color: "1D76DB", + description: "Fixes or improves an existing CLI harness", + }, + "cli-anything-skill": { + color: "5319E7", + description: "Changes CLI-Anything plugin or skill files", + }, + "cli-anything-hub": { + color: "FBCA04", + description: "Changes CLI-Hub, registries, or hub docs", + }, + "documentation": { + color: "0075CA", + description: "Documentation issue or improvement", + }, + "github-actions": { + color: "6F42C1", + description: "Changes GitHub Actions or automation", + }, +}; + +const SCRIPT_MANAGED_LABELS = ["new-cli", "existing-cli-fix", "documentation"]; +const REGISTRY_FILES = new Set([ + "registry.json", + "public_registry.json", + "matrix_registry.json", +]); + +function isHarnessFile(path) { + return /^[^/]+\/agent-harness\//.test(path); +} + +function isNewHarnessManifest(file) { + return ( + file.status === "added" && + /^[^/]+\/agent-harness\/(setup\.py|pyproject\.toml)$/.test(file.filename) + ); +} + +function isDocumentationFile(path) { + if (/(^|\/)SKILL\.md$/i.test(path)) { + return false; + } + + return ( + /^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); +} + +function computeScriptLabels(files, title) { + const paths = files.map((file) => file.filename); + const labelsToApply = new Set(); + + 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 || ""); + const documentationOnly = paths.length > 0 && paths.every(isDocumentationFile); + + if (hasNewHarness || registryNewCli) { + labelsToApply.add("new-cli"); + } else if (hasHarnessImplementationChange) { + labelsToApply.add("existing-cli-fix"); + } + + if (documentationOnly) { + labelsToApply.add("documentation"); + } + + return labelsToApply; +} + +async function ensureLabels(github, owner, repo, core) { + const existing = await github.paginate(github.rest.issues.listLabelsForRepo, { + owner, + repo, + per_page: 100, + }); + const existingNames = new Set(existing.map((label) => label.name)); + + for (const [name, definition] of Object.entries(LABELS)) { + if (existingNames.has(name)) { + continue; + } + + core.info(`Creating missing label: ${name}`); + await github.rest.issues.createLabel({ + owner, + repo, + name, + color: definition.color, + description: definition.description, + }); + } +} + +async function syncScriptLabels(github, owner, repo, pullNumber, currentLabels, labelsToApply, core) { + for (const label of labelsToApply) { + if (currentLabels.has(label)) { + continue; + } + + core.info(`Adding label: ${label}`); + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: pullNumber, + labels: [label], + }); + } + + for (const label of SCRIPT_MANAGED_LABELS) { + if (!currentLabels.has(label) || labelsToApply.has(label)) { + continue; + } + + core.info(`Removing label: ${label}`); + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: pullNumber, + name: label, + }); + } catch (error) { + if (error.status !== 404) { + throw error; + } + } + } +} + +module.exports = async ({ github, context, core }) => { + const pullRequest = context.payload.pull_request; + if (!pullRequest) { + core.info("No pull_request payload found; skipping PR labeling."); + return; + } + + const { owner, repo } = context.repo; + const pullNumber = pullRequest.number; + + await ensureLabels(github, owner, repo, core); + + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, + repo, + pull_number: pullNumber, + per_page: 100, + }); + + const labelsToApply = computeScriptLabels(files, pullRequest.title); + + const currentLabels = new Set((pullRequest.labels || []).map((label) => label.name)); + await syncScriptLabels(github, owner, repo, pullNumber, currentLabels, labelsToApply, core); +}; + +module.exports.computeScriptLabels = computeScriptLabels; +module.exports.LABELS = LABELS; +module.exports.SCRIPT_MANAGED_LABELS = SCRIPT_MANAGED_LABELS; diff --git a/.github/scripts/sync_root_skills.py b/.github/scripts/sync_root_skills.py new file mode 100644 index 0000000..bb614d7 --- /dev/null +++ b/.github/scripts/sync_root_skills.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Sync repo-root skills/ from harness-local SKILL.md files.""" + +from __future__ import annotations + +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +ROOT_SKILLS_DIR = REPO_ROOT / "skills" + + +def _canonical_skill_id(source: Path) -> str: + rel = source.relative_to(REPO_ROOT) + parts = rel.parts + if "cli_anything" in parts: + package_index = parts.index("cli_anything") + 1 + if package_index < len(parts): + package_name = parts[package_index] + return f"cli-anything-{package_name.replace('_', '-')}" + + software_dir = parts[0] + return f"cli-anything-{software_dir.replace('_', '-')}" + + +def _rewrite_name_frontmatter(content: str, skill_id: str) -> str: + if not content.startswith("---\n"): + return content + + parts = content.split("---\n", 2) + if len(parts) < 3: + return content + + _, frontmatter, body = parts + lines = frontmatter.splitlines(keepends=True) + rewritten: list[str] = [] + replaced = False + i = 0 + while i < len(lines): + line = lines[i] + if not replaced and line.startswith("name:"): + rewritten.append(f'name: "{skill_id}"\n') + replaced = True + i += 1 + while i < len(lines) and (lines[i].startswith(" ") or lines[i].startswith("\t")): + i += 1 + continue + rewritten.append(line) + i += 1 + + if not replaced: + rewritten.insert(0, f'name: "{skill_id}"\n') + + frontmatter = "".join(rewritten) + return f"---\n{frontmatter}---\n{body}" + + +def _discover_sources() -> list[Path]: + sources: list[Path] = [] + sources.extend(sorted(REPO_ROOT.glob("*/agent-harness/cli_anything/*/skills/SKILL.md"))) + sources.extend(sorted(REPO_ROOT.glob("*/agent-harness/cli_anything/*/SKILL.md"))) + return [path for path in sources if path.is_file()] + + +def main() -> int: + sources = _discover_sources() + ROOT_SKILLS_DIR.mkdir(parents=True, exist_ok=True) + + written = 0 + for source in sources: + skill_id = _canonical_skill_id(source) + target = ROOT_SKILLS_DIR / skill_id / "SKILL.md" + target.parent.mkdir(parents=True, exist_ok=True) + content = source.read_text(encoding="utf-8") + expected = _rewrite_name_frontmatter(content, skill_id) + + if target.is_file() and target.read_text(encoding="utf-8") == expected: + continue + + # Overwriting is destructive: the mirror may hold edits the source lacks. + # Say so, so the loss is never silent. validate_root_skills.py refuses + # in that direction before CI ever reaches here. + action = "overwrite" if target.is_file() else "create" + print(f"{action}: {target.relative_to(REPO_ROOT)} <- {source.relative_to(REPO_ROOT)}") + target.write_text(expected, encoding="utf-8") + written += 1 + + print(f"Synced {written} root skill(s); {len(sources) - written} already up to date.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/tests/pr-labeler-fixtures.json b/.github/scripts/tests/pr-labeler-fixtures.json new file mode 100644 index 0000000..4756246 --- /dev/null +++ b/.github/scripts/tests/pr-labeler-fixtures.json @@ -0,0 +1,270 @@ +[ + { + "number": 262, + "title": "Add MseeP.ai badge", + "files": [ + {"filename": "README.md", "status": "modified"} + ], + "expected": ["documentation"] + }, + { + "number": 260, + "title": "feat: add hacker-feeds-cli to registry", + "files": [ + {"filename": "registry.json", "status": "modified"} + ], + "expected": ["cli-anything-hub", "new-cli"] + }, + { + "number": 259, + "title": "feat: add ve-twini to registry", + "files": [ + {"filename": "registry.json", "status": "modified"} + ], + "expected": ["cli-anything-hub", "new-cli"] + }, + { + "number": 258, + "title": "feat: add sliver to registry", + "files": [ + {"filename": "registry.json", "status": "modified"} + ], + "expected": ["cli-anything-hub", "new-cli"] + }, + { + "number": 256, + "title": "feat(Calibre): add a CLI-Anything harness for Calibre Desktop", + "files": [ + {"filename": ".gitignore", "status": "modified"}, + {"filename": "README.md", "status": "modified"}, + {"filename": "calibre/agent-harness/CALIBRE.md", "status": "added"}, + {"filename": "calibre/agent-harness/cli_anything/calibre/calibre_cli.py", "status": "added"}, + {"filename": "calibre/agent-harness/cli_anything/calibre/core/session.py", "status": "added"}, + {"filename": "calibre/agent-harness/cli_anything/calibre/skills/SKILL.md", "status": "added"}, + {"filename": "calibre/agent-harness/setup.py", "status": "added"}, + {"filename": "cli-anything-plugin/scripts/setup-cli-anything.sh", "status": "modified"}, + {"filename": "codex-skill/scripts/install.sh", "status": "modified"}, + {"filename": "registry.json", "status": "modified"}, + {"filename": "skills/cli-anything-calibre/SKILL.md", "status": "added"} + ], + "expected": ["cli-anything-hub", "cli-anything-skill", "new-cli"] + }, + { + "number": 254, + "title": "feat: add quietshrink harness - Apple Silicon screen recording compressor", + "files": [ + {"filename": ".gitignore", "status": "modified"}, + {"filename": "quietshrink/agent-harness/QUIETSHRINK.md", "status": "added"}, + {"filename": "quietshrink/agent-harness/cli_anything/quietshrink/quietshrink_cli.py", "status": "added"}, + {"filename": "quietshrink/agent-harness/cli_anything/quietshrink/skills/SKILL.md", "status": "added"}, + {"filename": "quietshrink/agent-harness/setup.py", "status": "added"}, + {"filename": "registry.json", "status": "modified"}, + {"filename": "skills/cli-anything-quietshrink/SKILL.md", "status": "added"} + ], + "expected": ["cli-anything-hub", "cli-anything-skill", "new-cli"] + }, + { + "number": 252, + "title": "Add cli-anything-rekordbox: Pioneer Rekordbox 6/7 harness", + "files": [ + {"filename": ".gitignore", "status": "modified"}, + {"filename": "ableton/agent-harness/setup.py", "status": "added"}, + {"filename": "ableton/agent-harness/cli_anything/ableton/ableton_cli.py", "status": "added"}, + {"filename": "rekordbox/agent-harness/setup.py", "status": "added"}, + {"filename": "rekordbox/agent-harness/cli_anything/rekordbox/rekordbox_cli.py", "status": "added"}, + {"filename": "rekordbox/agent-harness/cli_anything/rekordbox/skills/SKILL.md", "status": "added"}, + {"filename": "serum/agent-harness/setup.py", "status": "added"}, + {"filename": "vital/agent-harness/setup.py", "status": "added"}, + {"filename": "skills/cli-anything-rekordbox/SKILL.md", "status": "added"} + ], + "expected": ["cli-anything-skill", "new-cli"] + }, + { + "number": 251, + "title": "feat: add s&box CLI harness", + "files": [ + {"filename": ".gitignore", "status": "modified"}, + {"filename": "README.md", "status": "modified"}, + {"filename": "registry.json", "status": "modified"}, + {"filename": "sbox/agent-harness/SBOX.md", "status": "added"}, + {"filename": "sbox/agent-harness/cli_anything/sbox/sbox_cli.py", "status": "added"}, + {"filename": "sbox/agent-harness/cli_anything/sbox/skills/SKILL.md", "status": "added"}, + {"filename": "sbox/agent-harness/setup.py", "status": "added"}, + {"filename": "skills/cli-anything-sbox/SKILL.md", "status": "added"} + ], + "expected": ["cli-anything-hub", "cli-anything-skill", "new-cli"] + }, + { + "number": 245, + "title": "feat: mature lldb agent harness", + "files": [ + {"filename": "lldb/agent-harness/LLDB.md", "status": "modified"}, + {"filename": "lldb/agent-harness/cli_anything/lldb/lldb_cli.py", "status": "modified"}, + {"filename": "lldb/agent-harness/cli_anything/lldb/skills/SKILL.md", "status": "modified"}, + {"filename": "lldb/agent-harness/setup.py", "status": "modified"}, + {"filename": "registry.json", "status": "modified"}, + {"filename": "skills/cli-anything-lldb/SKILL.md", "status": "modified"} + ], + "expected": ["cli-anything-hub", "cli-anything-skill", "existing-cli-fix"] + }, + { + "number": 244, + "title": "feat: improve Unreal Insights live analysis", + "files": [ + {"filename": "skills/cli-anything-unrealinsights/SKILL.md", "status": "modified"}, + {"filename": "unrealinsights/agent-harness/UNREALINSIGHTS.md", "status": "modified"}, + {"filename": "unrealinsights/agent-harness/cli_anything/unrealinsights/core/analyze.py", "status": "added"}, + {"filename": "unrealinsights/agent-harness/cli_anything/unrealinsights/skills/SKILL.md", "status": "modified"}, + {"filename": "unrealinsights/agent-harness/cli_anything/unrealinsights/unrealinsights_cli.py", "status": "modified"} + ], + "expected": ["cli-anything-skill", "existing-cli-fix"] + }, + { + "number": 241, + "title": "Feat/add firefly iii cli", + "files": [ + {"filename": ".gitignore", "status": "modified"}, + {"filename": "firefly-iii/agent-harness/README.md", "status": "added"}, + {"filename": "firefly-iii/agent-harness/cli_anything/firefly_iii/firefly_iii_cli.py", "status": "added"}, + {"filename": "firefly-iii/agent-harness/cli_anything/firefly_iii/skills/SKILL.md", "status": "added"}, + {"filename": "firefly-iii/agent-harness/setup.py", "status": "added"}, + {"filename": "firefly-iii/agent-harness/skills/cli-anything-firefly-iii/SKILL.md", "status": "added"} + ], + "expected": ["cli-anything-skill", "new-cli"] + }, + { + "number": 240, + "title": "fix(blender): align docs and render execute with real behavior", + "files": [ + {"filename": "README.md", "status": "modified"}, + {"filename": "README_CN.md", "status": "modified"}, + {"filename": "blender/agent-harness/cli_anything/blender/README.md", "status": "modified"}, + {"filename": "blender/agent-harness/cli_anything/blender/blender_cli.py", "status": "modified"}, + {"filename": "blender/agent-harness/cli_anything/blender/core/render.py", "status": "modified"}, + {"filename": "blender/agent-harness/cli_anything/blender/utils/blender_backend.py", "status": "modified"} + ], + "expected": ["existing-cli-fix"] + }, + { + "number": 238, + "title": "feat: add NSLogger CLI harness", + "files": [ + {"filename": ".gitignore", "status": "modified"}, + {"filename": "README.md", "status": "modified"}, + {"filename": "nslogger/agent-harness/NSLOGGER.md", "status": "added"}, + {"filename": "nslogger/agent-harness/cli_anything/nslogger/nslogger_cli.py", "status": "added"}, + {"filename": "nslogger/agent-harness/cli_anything/nslogger/skills/SKILL.md", "status": "added"}, + {"filename": "nslogger/agent-harness/setup.py", "status": "added"}, + {"filename": "registry.json", "status": "modified"}, + {"filename": "skills/cli-anything-nslogger/SKILL.md", "status": "added"} + ], + "expected": ["cli-anything-hub", "cli-anything-skill", "new-cli"] + }, + { + "number": 237, + "title": "refactor(openclaw-skill->macrocli): rename, complete backends, add recorder and visual anchor", + "files": [ + {"filename": "macrocli/SKILL.md", "status": "added"}, + {"filename": "macrocli/agent-harness/MACROCLI.md", "status": "added"}, + {"filename": "macrocli/agent-harness/cli_anything/macrocli/macrocli_cli.py", "status": "added"}, + {"filename": "macrocli/agent-harness/cli_anything/macrocli/skills/SKILL.md", "status": "added"}, + {"filename": "macrocli/agent-harness/setup.py", "status": "added"}, + {"filename": "openclaw-skill/agent-harness/cli_anything/openclaw/openclaw_cli.py", "status": "modified"}, + {"filename": "registry.json", "status": "modified"}, + {"filename": "skills/cli-anything-macrocli/SKILL.md", "status": "added"} + ], + "expected": ["cli-anything-hub", "cli-anything-skill", "new-cli"] + }, + { + "number": 233, + "title": "fix(browser): add MCP timeout guard to prevent fs hangs", + "files": [ + {"filename": "browser/agent-harness/cli_anything/browser/browser_cli.py", "status": "modified"}, + {"filename": "browser/agent-harness/cli_anything/browser/skills/SKILL.md", "status": "modified"}, + {"filename": "browser/agent-harness/cli_anything/browser/utils/domshell_backend.py", "status": "modified"}, + {"filename": "browser/agent-harness/setup.py", "status": "modified"}, + {"filename": "registry.json", "status": "modified"} + ], + "expected": ["cli-anything-hub", "cli-anything-skill", "existing-cli-fix"] + }, + { + "number": 220, + "title": "fix(browser): harden bridge and sync REPL template", + "files": [ + {"filename": ".github/workflows/pr-ci.yml", "status": "added"}, + {"filename": "browser/agent-harness/cli_anything/browser/browser_cli.py", "status": "modified"}, + {"filename": "browser/agent-harness/cli_anything/browser/utils/repl_skin.py", "status": "modified"}, + {"filename": "cli-anything-plugin/repl_skin.py", "status": "modified"}, + {"filename": "gimp/agent-harness/cli_anything/gimp/utils/repl_skin.py", "status": "modified"} + ], + "expected": ["cli-anything-skill", "existing-cli-fix", "github-actions"] + }, + { + "number": 218, + "title": "fix: Executing cli-anything-browser --json fs ls yields no return result", + "files": [ + {"filename": "browser/agent-harness/cli_anything/browser/tests/test_core.py", "status": "modified"}, + {"filename": "browser/agent-harness/cli_anything/browser/utils/domshell_backend.py", "status": "modified"} + ], + "expected": ["existing-cli-fix"] + }, + { + "number": 202, + "title": "security: add user confirmation guard to meta-skill installation flow", + "files": [ + {"filename": "cli-hub-meta-skill/SKILL.md", "status": "modified"}, + {"filename": "docs/hub/SKILL.md", "status": "modified"} + ], + "expected": ["cli-anything-hub", "cli-anything-skill"] + }, + { + "number": 189, + "title": "feat: add MiniMax CLI harness (chat + TTS)", + "files": [ + {"filename": ".gitignore", "status": "modified"}, + {"filename": "minimax/agent-harness/cli_anything/minimax/minimax_cli.py", "status": "added"}, + {"filename": "minimax/agent-harness/cli_anything/minimax/skills/SKILL.md", "status": "added"}, + {"filename": "minimax/agent-harness/setup.py", "status": "added"}, + {"filename": "registry.json", "status": "modified"} + ], + "expected": ["cli-anything-hub", "cli-anything-skill", "new-cli"] + }, + { + "number": 187, + "title": "Refactor AnyGen CLI for better state management", + "files": [ + {"filename": "anygen/agent-harness/cli_anything/anygen/anygen_cli.py", "status": "modified"} + ], + "expected": ["existing-cli-fix"] + }, + { + "number": 167, + "title": "feat(freecad): add FreeCAD 1.0.2 backward compatibility", + "files": [ + {"filename": "freecad/agent-harness/FREECAD.md", "status": "modified"}, + {"filename": "freecad/agent-harness/cli_anything/freecad/core/generate.py", "status": "modified"}, + {"filename": "freecad/agent-harness/cli_anything/freecad/freecad_cli.py", "status": "modified"}, + {"filename": "freecad/agent-harness/cli_anything/freecad/skills/SKILL.md", "status": "modified"}, + {"filename": "registry.json", "status": "modified"} + ], + "expected": ["cli-anything-hub", "cli-anything-skill", "existing-cli-fix"] + }, + { + "number": 106, + "title": "fix: split token-aware into default exclusions + optional --economic mode", + "files": [ + {"filename": "cli-anything-plugin/HARNESS.md", "status": "modified"}, + {"filename": "cli-anything-plugin/commands/cli-anything.md", "status": "modified"}, + {"filename": "cli-anything-plugin/commands/refine.md", "status": "modified"} + ], + "expected": ["cli-anything-skill"] + }, + { + "number": "registry-maintenance", + "title": "Update registry dates", + "files": [ + {"filename": "registry.json", "status": "modified"} + ], + "expected": ["cli-anything-hub"] + } +] diff --git a/.github/scripts/tests/pr-labeler.test.js b/.github/scripts/tests/pr-labeler.test.js new file mode 100644 index 0000000..6c48a4b --- /dev/null +++ b/.github/scripts/tests/pr-labeler.test.js @@ -0,0 +1,208 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); + +const labeler = require("../pr-labeler.js"); +const fixtures = require("./pr-labeler-fixtures.json"); + +const REPO_ROOT = path.resolve(__dirname, "../../.."); +const LABELER_CONFIG_PATH = path.join(REPO_ROOT, ".github/labeler.yml"); +const EXPECTED_LABELS = [ + "cli-anything-hub", + "cli-anything-skill", + "documentation", + "existing-cli-fix", + "github-actions", + "new-cli", +]; + +function normalizeLabels(labels) { + return [...labels].sort(); +} + +function sameLabels(left, right) { + return JSON.stringify(left) === JSON.stringify(right); +} + +function loadLabelerPatterns() { + const patternsByLabel = new Map(); + let currentLabel = null; + + for (const line of fs.readFileSync(LABELER_CONFIG_PATH, "utf8").split(/\r?\n/)) { + const labelMatch = line.match(/^([a-z0-9-]+):$/); + if (labelMatch) { + currentLabel = labelMatch[1]; + patternsByLabel.set(currentLabel, []); + continue; + } + + const patternMatch = line.match(/-\s+"([^"]+)"/); + if (currentLabel && patternMatch) { + patternsByLabel.get(currentLabel).push(patternMatch[1]); + } + } + + return patternsByLabel; +} + +function globToRegex(pattern) { + let regex = "^"; + + for (let index = 0; index < pattern.length; index += 1) { + const char = pattern[index]; + const next = pattern[index + 1]; + const afterNext = pattern[index + 2]; + + if (char === "*" && next === "*" && afterNext === "/") { + regex += "(?:.*/)?"; + index += 2; + continue; + } + + if (char === "*" && next === "*") { + regex += ".*"; + index += 1; + continue; + } + + if (char === "*") { + regex += "[^/]*"; + continue; + } + + regex += char.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); + } + + regex += "$"; + return new RegExp(regex); +} + +function computePathLabels(files) { + const labels = new Set(); + const patternsByLabel = loadLabelerPatterns(); + + for (const [label, patterns] of patternsByLabel.entries()) { + const regexes = patterns.map(globToRegex); + if (files.some((file) => regexes.some((regex) => regex.test(file.filename)))) { + labels.add(label); + } + } + + return labels; +} + +function computeAllLabels(sample) { + const labels = new Set(labeler.computeScriptLabels(sample.files, sample.title)); + for (const pathLabel of computePathLabels(sample.files)) { + labels.add(pathLabel); + } + return normalizeLabels(labels); +} + +function summarizeMetrics(results) { + const labels = new Set(EXPECTED_LABELS); + for (const result of results) { + for (const label of result.expected) labels.add(label); + for (const label of result.predicted) labels.add(label); + } + + const perLabel = {}; + for (const label of labels) { + let truePositive = 0; + let falsePositive = 0; + let falseNegative = 0; + + for (const result of results) { + const expected = new Set(result.expected); + const predicted = new Set(result.predicted); + + if (expected.has(label) && predicted.has(label)) truePositive += 1; + if (!expected.has(label) && predicted.has(label)) falsePositive += 1; + if (expected.has(label) && !predicted.has(label)) falseNegative += 1; + } + + const precisionDenominator = truePositive + falsePositive; + const recallDenominator = truePositive + falseNegative; + + perLabel[label] = { + truePositive, + falsePositive, + falseNegative, + precision: precisionDenominator === 0 ? 1 : truePositive / precisionDenominator, + recall: recallDenominator === 0 ? 1 : truePositive / recallDenominator, + }; + } + + const exactMatches = results.filter((result) => sameLabels(result.predicted, result.expected)).length; + + return { + exactAccuracy: exactMatches / results.length, + perLabel, + }; +} + +test("real PR fixture label accuracy and recall", () => { + const results = fixtures.map((sample) => ({ + number: sample.number, + title: sample.title, + expected: normalizeLabels(sample.expected), + predicted: computeAllLabels(sample), + })); + + const mismatches = results.filter((result) => { + return !sameLabels(result.predicted, result.expected); + }); + + const metrics = summarizeMetrics(results); + + assert.deepStrictEqual(mismatches, [], JSON.stringify({mismatches, metrics}, null, 2)); + assert.equal(metrics.exactAccuracy, 1); + + for (const [label, metric] of Object.entries(metrics.perLabel)) { + assert.equal(metric.precision, 1, `${label} precision: ${JSON.stringify(metric)}`); + assert.equal(metric.recall, 1, `${label} recall: ${JSON.stringify(metric)}`); + } +}); + +test("labeler config and script agree on the supported label set", () => { + const configLabels = new Set(loadLabelerPatterns().keys()); + const scriptLabels = new Set(Object.keys(labeler.LABELS)); + for (const label of EXPECTED_LABELS) { + assert(scriptLabels.has(label), `${label} must be creatable by pr-labeler.js`); + } + + assert(configLabels.has("cli-anything-hub")); + assert(configLabels.has("cli-anything-skill")); + assert(configLabels.has("github-actions")); +}); + +test("registry-only maintenance is not treated as a new CLI", () => { + const labels = computeAllLabels({ + title: "Update registry dates", + files: [{filename: "registry.json", status: "modified"}], + }); + + assert.deepStrictEqual(labels, ["cli-anything-hub"]); +}); + +test("mixed README and harness changes are not documentation-only", () => { + const labels = computeAllLabels({ + title: "fix(blender): update docs and render behavior", + files: [ + {filename: "README.md", status: "modified"}, + {filename: "blender/agent-harness/cli_anything/blender/core/render.py", status: "modified"}, + ], + }); + + 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 new file mode 100644 index 0000000..5c9abd2 --- /dev/null +++ b/.github/scripts/tests/test_update_registry_dates.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path + + +SCRIPT_PATH = Path(__file__).resolve().parents[1] / "update_registry_dates.py" +SPEC = importlib.util.spec_from_file_location("update_registry_dates", SCRIPT_PATH) +MODULE = importlib.util.module_from_spec(SPEC) +assert SPEC and SPEC.loader +SPEC.loader.exec_module(MODULE) + + +def test_resolve_harness_path_prefers_install_subdirectory_for_qgis(): + cli = { + "name": "qgis", + "install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=QGIS/agent-harness", + "skill_md": "QGIS/agent-harness/cli_anything/qgis/skills/SKILL.md", + } + + path = MODULE.resolve_harness_path(cli, MODULE.REPO_ROOT) + + assert path == MODULE.REPO_ROOT / "QGIS" / "agent-harness" + + +def test_resolve_harness_path_handles_underscore_directory_names(): + cli = { + "name": "unimol_tools", + "install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=unimol_tools/agent-harness", + "skill_md": "skills/cli-anything-unimol-tools/SKILL.md", + } + + path = MODULE.resolve_harness_path(cli, MODULE.REPO_ROOT) + + assert path == MODULE.REPO_ROOT / "unimol_tools" / "agent-harness" + + +def test_extract_external_source_url_from_cargo_git_install(): + cli = { + "name": "clibrowser", + "install_cmd": "cargo install --git https://github.com/allthingssecurity/clibrowser.git --tag v0.1.0 --locked", + "source_url": None, + } + + source_url = MODULE.extract_external_source_url(cli) + + assert source_url == "https://github.com/allthingssecurity/clibrowser" + + +def test_extract_npm_package_supports_scoped_package_names(): + cli = { + "npm_package": "@sentry/cli", + "install_cmd": "npm install -g @sentry/cli", + } + + assert MODULE._extract_npm_package(cli) == "@sentry/cli" + + +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" + + +def test_extract_pypi_package_skips_editable_option_value(): + install_cmd = "pip install -e ./local/pkg py4csr" + + assert MODULE._extract_pypi_package(install_cmd) == "py4csr" + + +def test_extract_pypi_package_returns_none_for_editable_only_install(): + install_cmd = "pip install -e ." + + assert MODULE._extract_pypi_package(install_cmd) is None diff --git a/.github/scripts/update_registry_dates.py b/.github/scripts/update_registry_dates.py new file mode 100644 index 0000000..61e3643 --- /dev/null +++ b/.github/scripts/update_registry_dates.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +"""Update registry-dates.json with meaningful per-CLI update dates.""" + +from __future__ import annotations + +import json +import re +import shlex +import subprocess +import urllib.parse +import urllib.request +from datetime import datetime, timezone +from email.utils import parsedate_to_datetime +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +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", + "-e", + "--editable", + "-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: + try: + req = urllib.request.Request( + url, + headers={ + "Accept": "application/json", + "User-Agent": USER_AGENT, + }, + ) + with urllib.request.urlopen(req, timeout=10) as resp: + return json.loads(resp.read()) + except Exception: + return None + + +def _fetch_last_modified(url: str) -> str | None: + try: + req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}, method="HEAD") + with urllib.request.urlopen(req, timeout=10) as resp: + last_modified = resp.headers.get("Last-Modified") + if not last_modified: + return None + return parsedate_to_datetime(last_modified).astimezone(timezone.utc).strftime("%Y-%m-%d") + except Exception: + return None + + +def _git_log_timestamp(target_path: Path, excluded_globs: tuple[str, ...] = ()) -> int | None: + try: + relative_target = target_path.relative_to(REPO_ROOT).as_posix() + cmd = ["git", "log", "-1", "--format=%ct", "--", relative_target] + cmd.extend(f":(exclude,glob){pattern}" for pattern in excluded_globs) + result = subprocess.run( + cmd, + capture_output=True, + text=True, + check=True, + cwd=REPO_ROOT, + ) + return int(result.stdout.strip()) + except (subprocess.CalledProcessError, ValueError): + return None + + +def get_last_modified(target_path: Path) -> str | None: + """Get the most recent git commit date for CLI-specific files in a repo path.""" + relative_target = target_path.relative_to(REPO_ROOT).as_posix() + shared_file_globs = ( + f"{relative_target}/cli_anything/**/utils/repl_skin.py", + f"{relative_target}/cli_anything/**/skills/SKILL.md", + f"{relative_target}/cli_anything/**/SKILL.md", + ) + + timestamp = _git_log_timestamp(target_path, excluded_globs=shared_file_globs) + if timestamp is None: + timestamp = _git_log_timestamp(target_path) + if timestamp is None: + return None + + try: + return datetime.fromtimestamp(timestamp, tz=timezone.utc).strftime("%Y-%m-%d") + except (OverflowError, OSError, ValueError): + return None + + +def get_github_repo_date(source_url: str) -> str | None: + """Get the last push date from a GitHub repo via the API.""" + match = GITHUB_REPO_RE.match(source_url) + if not match: + return None + repo_slug = match.group(1) + data = _fetch_json(f"https://api.github.com/repos/{repo_slug}") + if not data: + return None + pushed_at = data.get("pushed_at") + return pushed_at[:10] if pushed_at else None + + +def _extract_pypi_package(install_cmd: str) -> str | None: + if not install_cmd: + return None + try: + tokens = shlex.split(install_cmd) + except ValueError: + return None + + if not tokens: + return None + + install_index = None + if ( + len(tokens) >= 3 + and tokens[0] in {"python", "python3"} + and tokens[1:3] == ["-m", "pip"] + ): + install_index = 3 + elif tokens[0] in {"pip", "pip3"}: + install_index = 1 + + if install_index is None or install_index >= len(tokens) or tokens[install_index] != "install": + return None + + 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 + return token + return None + + +def get_pypi_date(install_cmd: str) -> str | None: + """Get the last release date from PyPI for a pip-installable package.""" + package = _extract_pypi_package(install_cmd) + if not package: + return None + data = _fetch_json(f"https://pypi.org/pypi/{package}/json") + if not data: + return None + latest = data.get("info", {}).get("version") + releases = data.get("releases", {}) + release_files = releases.get(latest or "", []) + if not release_files: + return None + upload_time = release_files[0].get("upload_time") or release_files[0].get("upload_time_iso_8601") + return upload_time[:10] if upload_time else None + + +def _extract_npm_package(cli: dict) -> str | None: + package = cli.get("npm_package") + if package: + return package + + install_cmd = cli.get("install_cmd", "") + match = re.search(r"npm install -g (\S+)", install_cmd) + return match.group(1) if match else None + + +def get_npm_date(cli: dict) -> str | None: + """Get the latest publish date from the npm registry.""" + package = _extract_npm_package(cli) + if not package: + return None + encoded = urllib.parse.quote(package, safe="") + data = _fetch_json(f"https://registry.npmjs.org/{encoded}") + if not data: + return None + latest = data.get("dist-tags", {}).get("latest") + published = data.get("time", {}).get(latest or "") + return published[:10] if published else None + + +def _extract_install_subdirectory(cli: dict) -> str | None: + install_cmd = cli.get("install_cmd") or "" + match = SUBDIRECTORY_RE.search(install_cmd) + return match.group(1) if match else None + + +def _extract_skill_subdirectory(cli: dict) -> str | None: + skill_md = cli.get("skill_md") + if not skill_md or skill_md.startswith("http"): + return None + marker = "/agent-harness/" + if marker not in skill_md: + return None + return skill_md.split(marker, 1)[0] + marker.rstrip("/") + + +def resolve_harness_path(cli: dict, repo_root: Path) -> Path | None: + """Resolve the on-disk harness path for an in-repo CLI entry.""" + for relative in (_extract_install_subdirectory(cli), _extract_skill_subdirectory(cli)): + if relative: + candidate = repo_root / relative + if candidate.exists(): + return candidate + + candidate_dirs = [] + for name in (cli.get("name"), cli.get("name", "").replace("-", "_"), cli.get("name", "").replace("_", "-")): + if name and name not in candidate_dirs: + candidate_dirs.append(name) + + for directory in candidate_dirs: + candidate = repo_root / directory / "agent-harness" + if candidate.exists(): + return candidate + return None + + +def extract_external_source_url(cli: dict) -> str | None: + """Best-effort source URL discovery for third-party CLIs.""" + source_url = cli.get("source_url") + if source_url: + return source_url + + install_cmd = cli.get("install_cmd") or "" + git_match = GIT_URL_RE.search(install_cmd) + if git_match: + return git_match.group(0).removesuffix(".git") + + for field in ("homepage", "docs_url"): + value = cli.get(field) + if value and "github.com/" in value: + return value + return None + + +def get_external_date(cli: dict) -> str | None: + """Get a useful update date for external/public CLIs.""" + source_url = extract_external_source_url(cli) + if source_url: + date = get_github_repo_date(source_url) + if date: + return date + + package_manager = (cli.get("package_manager") or "").lower() + if package_manager == "npm": + date = get_npm_date(cli) + if date: + return date + + date = get_pypi_date(cli.get("install_cmd", "")) + if date: + return date + + for field in ("homepage", "docs_url"): + url = cli.get(field) + if url: + date = _fetch_last_modified(url) + if date: + return date + return None + + +def get_cli_date(cli: dict, repo_root: Path) -> str | None: + harness_path = resolve_harness_path(cli, repo_root) + if harness_path: + return get_last_modified(harness_path) + return get_external_date(cli) + + +def _load_registry(path: Path) -> list[dict]: + with path.open(encoding="utf-8") as f: + return json.load(f)["clis"] + + +def main() -> None: + dates_path = REPO_ROOT / "docs" / "hub" / "registry-dates.json" + all_clis = _load_registry(REPO_ROOT / "registry.json") + _load_registry(REPO_ROOT / "public_registry.json") + + dates = {cli["name"]: get_cli_date(cli, REPO_ROOT) for cli in all_clis} + + with dates_path.open("w", encoding="utf-8") as f: + json.dump(dates, f, indent=2) + + print(f"Updated dates for {len(dates)} CLI entries") + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/validate_root_skills.py b/.github/scripts/validate_root_skills.py new file mode 100644 index 0000000..adab7c7 --- /dev/null +++ b/.github/scripts/validate_root_skills.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Validate that deep harness SKILL.md files are mirrored in repo-root skills/.""" + +from __future__ import annotations + +import difflib +import sys +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def _mirror_only_lines(expected: str, actual: str) -> list[str]: + """Lines present in the root mirror but absent from the regenerated source. + + These are exactly what `sync_root_skills.py` would overwrite and destroy, + because it regenerates the mirror from the harness source unconditionally. + """ + diff = difflib.unified_diff( + expected.splitlines(), actual.splitlines(), lineterm="", n=0 + ) + return [ + line[1:].strip() + for line in diff + if line.startswith("+") and not line.startswith("+++") and line[1:].strip() + ] + + +def _load_sync_helpers(): + namespace: dict[str, object] = {"__file__": str(REPO_ROOT / ".github" / "scripts" / "sync_root_skills.py")} + sync_script = REPO_ROOT / ".github" / "scripts" / "sync_root_skills.py" + exec(sync_script.read_text(encoding="utf-8"), namespace) + return namespace + + +def main() -> int: + sync = _load_sync_helpers() + discover_sources = sync["_discover_sources"] + canonical_skill_id = sync["_canonical_skill_id"] + rewrite_name_frontmatter = sync["_rewrite_name_frontmatter"] + root_skills_dir = sync["ROOT_SKILLS_DIR"] + + # Drift where the mirror holds content the source lacks. Regenerating would + # delete it, so these must be resolved by editing the source instead. + clobber: list[tuple[Path, Path, list[str]]] = [] + # Drift the sync script can safely repair: the source moved ahead. + stale: list[str] = [] + + for source in discover_sources(): + skill_id = canonical_skill_id(source) + target = root_skills_dir / skill_id / "SKILL.md" + if not target.is_file(): + stale.append( + f"Missing root skill for {source.relative_to(REPO_ROOT)}: expected {target.relative_to(REPO_ROOT)}" + ) + continue + + source_content = source.read_text(encoding="utf-8") + expected = rewrite_name_frontmatter(source_content, skill_id) + actual = target.read_text(encoding="utf-8") + if actual == expected: + continue + + mirror_only = _mirror_only_lines(expected, actual) + if mirror_only: + clobber.append((source, target, mirror_only)) + else: + stale.append( + f"Out-of-sync root skill for {source.relative_to(REPO_ROOT)}: {target.relative_to(REPO_ROOT)}" + ) + + if not clobber and not stale: + print("Root skills validation passed.") + return 0 + + print("Root skills validation failed:", file=sys.stderr) + + if clobber: + print( + "\nYou edited a GENERATED file. `skills/` is produced from the harness\n" + "SKILL.md sources; running the sync script would DELETE these edits.", + file=sys.stderr, + ) + for source, target, mirror_only in clobber: + print(f"\n- {target.relative_to(REPO_ROOT)}", file=sys.stderr) + print( + f" contains content not present in {source.relative_to(REPO_ROOT)}:", + file=sys.stderr, + ) + for line in mirror_only[:10]: + print(f" + {line}", file=sys.stderr) + if len(mirror_only) > 10: + print(f" … and {len(mirror_only) - 10} more line(s)", file=sys.stderr) + print( + f" Fix: move the change into {source.relative_to(REPO_ROOT)},\n" + f" then run `python3 .github/scripts/sync_root_skills.py`.", + file=sys.stderr, + ) + + if stale: + print("\nThe following root skills are stale and can be regenerated:", file=sys.stderr) + for error in stale: + print(f"- {error}", file=sys.stderr) + print( + "Run `python3 .github/scripts/sync_root_skills.py` and commit the updated root skills.", + file=sys.stderr, + ) + + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/check-codex-skill.yml b/.github/workflows/check-codex-skill.yml new file mode 100644 index 0000000..829d02b --- /dev/null +++ b/.github/workflows/check-codex-skill.yml @@ -0,0 +1,31 @@ +name: Check Codex Skill + +on: + pull_request: + paths: + - 'codex-skill/**' + - 'cli-anything-plugin/**' + - 'docs/PREVIEW_PROTOCOL.md' + - '.github/workflows/check-codex-skill.yml' + push: + branches: + - main + paths: + - 'codex-skill/**' + - 'cli-anything-plugin/**' + - 'docs/PREVIEW_PROTOCOL.md' + - '.github/workflows/check-codex-skill.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + test-installer: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Test Codex skill installer + run: bash codex-skill/tests/test_install.sh diff --git a/.github/workflows/check-root-skills.yml b/.github/workflows/check-root-skills.yml new file mode 100644 index 0000000..0240172 --- /dev/null +++ b/.github/workflows/check-root-skills.yml @@ -0,0 +1,35 @@ +name: Check Root Skills + +on: + pull_request: + paths: + - '*/agent-harness/**' + - 'skills/**' + - '.github/scripts/sync_root_skills.py' + - '.github/scripts/validate_root_skills.py' + - '.github/workflows/check-root-skills.yml' + push: + branches: + - main + paths: + - '*/agent-harness/**' + - 'skills/**' + - '.github/scripts/sync_root_skills.py' + - '.github/scripts/validate_root_skills.py' + - '.github/workflows/check-root-skills.yml' + workflow_dispatch: + +jobs: + validate-root-skills: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Validate root skills mirror + run: python3 .github/scripts/validate_root_skills.py diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml new file mode 100644 index 0000000..8e55a3e --- /dev/null +++ b/.github/workflows/deploy-pages.yml @@ -0,0 +1,101 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: + - main + paths: + - '*/agent-harness/**' + - 'registry.json' + - 'public_registry.json' + - 'matrix_registry.json' + - 'cli-hub/**' + - 'cli-hub-matrix/**' + - 'docs/hub/**' + - '.github/workflows/deploy-pages.yml' + - '.github/scripts/update_registry_dates.py' + - '.github/scripts/generate_meta_skill.py' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Generate registry dates + run: python3 .github/scripts/update_registry_dates.py + + - name: Generate meta-skill + run: python3 .github/scripts/generate_meta_skill.py + + - name: Install AWS CLI + run: pip install awscli + + - name: Upload SKILL.md to DO Spaces + env: + AWS_ACCESS_KEY_ID: ${{ secrets.DO_SPACES_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.DO_SPACES_SECRET }} + run: | + aws s3 cp cli-hub-skill/SKILL.md \ + s3://${{ secrets.DO_SPACES_BUCKET }}/SKILL.md \ + --endpoint-url ${{ secrets.DO_SPACES_ENDPOINT }} \ + --acl public-read \ + --content-type text/markdown + + - name: Copy registries to hub for cli-hub access + run: | + cp registry.json docs/hub/registry.json + cp public_registry.json docs/hub/public_registry.json + + - name: Copy matrix_registry.json to hub for cli-hub access + run: cp matrix_registry.json docs/hub/matrix_registry.json + + - name: Build with Jekyll + uses: actions/jekyll-build-pages@v1 + with: + source: ./docs/hub + destination: ./docs/_site + + # Copied after the Jekyll build so SKILL.md front matter is served + # verbatim instead of being converted to HTML. Published at + # https://hkuds.github.io/CLI-Anything/matrix//{SKILL.md,references/,scripts/} + # (consumed by cli_hub.matrix_skill's published-URL fallback). + - name: Copy matrix skill content into site + run: | + sudo mkdir -p docs/_site/matrix + sudo rsync -a --exclude='__pycache__' --exclude='*.pyc' --exclude='*.pyo' \ + cli-hub-matrix/ docs/_site/matrix/ + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: './docs/_site' + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/pr-labeler-tests.yml b/.github/workflows/pr-labeler-tests.yml new file mode 100644 index 0000000..c3e41f4 --- /dev/null +++ b/.github/workflows/pr-labeler-tests.yml @@ -0,0 +1,43 @@ +name: PR Labeler Tests + +on: + pull_request: + paths: + - ".github/labeler.yml" + - ".github/scripts/pr-labeler.js" + - ".github/scripts/tests/pr-labeler-fixtures.json" + - ".github/scripts/tests/pr-labeler.test.js" + - ".github/workflows/pr-labeler.yml" + - ".github/workflows/pr-labeler-tests.yml" + push: + branches: + - main + paths: + - ".github/labeler.yml" + - ".github/scripts/pr-labeler.js" + - ".github/scripts/tests/pr-labeler-fixtures.json" + - ".github/scripts/tests/pr-labeler.test.js" + - ".github/workflows/pr-labeler.yml" + - ".github/workflows/pr-labeler-tests.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Validate PR labeler script + run: node --check .github/scripts/pr-labeler.js + + - name: Run PR labeler tests + run: node .github/scripts/tests/pr-labeler.test.js diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml new file mode 100644 index 0000000..0214c63 --- /dev/null +++ b/.github/workflows/pr-labeler.yml @@ -0,0 +1,33 @@ +name: Pull Request Labeler + +on: + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + label: + runs-on: ubuntu-latest + steps: + - name: Check out base branch + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.ref }} + persist-credentials: false + + - name: Apply computed labels + uses: actions/github-script@v7 + with: + script: | + const labelPullRequest = require("./.github/scripts/pr-labeler.js"); + await labelPullRequest({ github, context, core }); + + - name: Apply path labels + uses: actions/labeler@v6 + with: + configuration-path: .github/labeler.yml + sync-labels: true diff --git a/.github/workflows/publish-cli-hub.yml b/.github/workflows/publish-cli-hub.yml new file mode 100644 index 0000000..1cf62df --- /dev/null +++ b/.github/workflows/publish-cli-hub.yml @@ -0,0 +1,48 @@ +name: Publish cli-anything-hub to PyPI + +on: + push: + branches: [main] + paths: + - "cli-hub/**" + - "!cli-hub/tests/**" + - "!cli-hub/README.md" + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + id-token: write # required for trusted publishing + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Check if version already published + id: check + run: | + VERSION=$(python -c "import re; print(re.search(r'version=\"([^\"]+)\"', open('cli-hub/setup.py').read()).group(1))") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://pypi.org/pypi/cli-anything-hub/$VERSION/json") + if [ "$STATUS" = "200" ]; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "Version $VERSION already on PyPI — skipping." + else + echo "skip=false" >> "$GITHUB_OUTPUT" + echo "Version $VERSION not on PyPI — will publish." + fi + + - name: Build package + if: steps.check.outputs.skip == 'false' + run: | + pip install build + cd cli-hub + python -m build + + - name: Publish to PyPI + if: steps.check.outputs.skip == 'false' + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: cli-hub/dist/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bab309f --- /dev/null +++ b/.gitignore @@ -0,0 +1,345 @@ +# ============================================================ +# Only track: +# - cli-anything-plugin/** +# - codex-skill/** +# - */agent-harness/** (under each software dir) +# - .gitignore +# Everything else is ignored. +# ============================================================ + +# Step 1: Ignore everything at the root +/* + +# Step 2: Allow .gitignore, README, assets, and marketplace +!.gitignore +!/README.md +!/CITATION.cff +!/CONTRIBUTING.md +!/SECURITY.md +!/FIX_NOTES.md +!/assets/ +!/.pi-extension/ +/.pi-extension/cli-anything/node_modules +!/.claude-plugin/ +!/.github/ +/.github/* +!/.github/workflows/ +!/.github/scripts/ +!/.github/CODEOWNERS +!/.github/labeler.yml +!/.github/PULL_REQUEST_TEMPLATE.md +!/.github/ISSUE_TEMPLATE/ + +# Step 3: Allow cli-anything-plugin, codex-skill, and skill_generation tests +!/cli-anything-plugin/ +!/codex-skill/ +!/hermes-skill/ +!/reasonix-skill/ +!/skill_generation/ +!/openclaw-skill/ +!/cli-hub-meta-skill/ +!/cli-hub/ +!/cli-hub-matrix/ +!/skills/ +!/skills/** + +# Ignore cli-hub-skill (auto-generated, not tracked) +/cli-hub-skill/ + +# Step 4: Allow each software dir (top level only) +!/gimp/ +!/blender/ +!/inkscape/ +!/audacity/ +!/libreoffice/ +!/zotero/ +!/mubu/ +!/obs-studio/ +!/nslogger/ +!/kdenlive/ +!/shotcut/ +!/anygen/ +!/zoom/ +!/sketch/ +!/drawio/ +!/godot/ +!/mermaid/ +!/comfyui/ +!/dify-workflow/ +!/adguardhome/ +!/novita/ +!/minimax/ +!/ollama/ +!/browser/ +!/web-yu-pri/ +!/seaclip/ +!/pm2/ +!/chromadb/ +!/musescore/ +!/krita/ +!/freecad/ +!/iterm2/ +!/slay_the_spire_ii/ +!/eth2-quickstart/ +!/rms/ +!/renderdoc/ +!/cloudcompare/ +!/openscreen/ +!/QGIS/ +!/n8n/ +!/obsidian/ +!/joplin/ +!/unrealinsights/ +!/firefly-iii/ +!/nsight-graphics/ +!/lldb/ +!/macrocli/ +!/sbox/ +!/quietshrink/ +!/mailchimp/ +!/3MF/ +!/calibre/ +!/rekordbox/ +!/eez-studio/ +!/live2d/ +!/tigris/ +!/cc-switch/ +!/siyuan/ +# Step 5: Inside each software dir, ignore everything (including dotfiles) +/gimp/* +/gimp/.* +/blender/* +/blender/.* +/inkscape/* +/inkscape/.* +/audacity/* +/audacity/.* +/libreoffice/* +/libreoffice/.* +/zotero/* +/zotero/.* +/mubu/* +/mubu/.* +/obs-studio/* +/obs-studio/.* +/nslogger/* +/nslogger/.* +/kdenlive/* +/kdenlive/.* +/shotcut/* +/shotcut/.* +/anygen/* +/anygen/.* +/zoom/* +/zoom/.* +/sketch/* +/sketch/.* +/drawio/* +/drawio/.* +/godot/* +/godot/.* +/mermaid/* +/mermaid/.* +/comfyui/* +/comfyui/.* +/dify-workflow/* +/dify-workflow/.* +/adguardhome/* +/adguardhome/.* +/ollama/* +/ollama/.* +/browser/* +/browser/.* +/web-yu-pri/* +/web-yu-pri/.* +/seaclip/* +/seaclip/.* +/pm2/* +/pm2/.* +/chromadb/* +/chromadb/.* +/musescore/* +/musescore/.* +/krita/* +/krita/.* +/freecad/* +/freecad/.* +/iterm2/* +/iterm2/.* +/slay_the_spire_ii/* +/slay_the_spire_ii/.* +/eth2-quickstart/* +/eth2-quickstart/.* +/rms/* +/rms/.* +/renderdoc/* +/renderdoc/.* +/cloudcompare/* +/cloudcompare/.* +/openscreen/* +/openscreen/.* +/QGIS/* +/QGIS/.* +/3MF/* +/3MF/.* +/cloudanalyzer/* +/cloudanalyzer/.* +/wiremock/* +/wiremock/.* +/exa/* +/exa/.* +/n8n/* +/n8n/.* +/obsidian/* +/obsidian/.* +/joplin/* +/joplin/.* +/unrealinsights/* +/unrealinsights/.* +/nsight-graphics/* +/nsight-graphics/.* +/lldb/* +/lldb/.* +/macrocli/* +/macrocli/.* +/sbox/* +/sbox/.* +/quietshrink/* +/quietshrink/.* +/mailchimp/* +/mailchimp/.* +/calibre/* +/calibre/.* +/rekordbox/* +/rekordbox/.* +/eez-studio/* +/eez-studio/.* +/live2d/* +/live2d/.* +/tigris/* +/tigris/.* +/cc-switch/* +/cc-switch/.* +/siyuan/* +/siyuan/.* + +# Step 6: ...except agent-harness/ +!/gimp/agent-harness/ +!/blender/agent-harness/ +!/inkscape/agent-harness/ +!/audacity/agent-harness/ +!/wavetone/ +!/wavetone/agent-harness/ +!/libreoffice/agent-harness/ +!/zotero/agent-harness/ +!/mubu/agent-harness/ +!/obs-studio/agent-harness/ +!/nslogger/agent-harness/ +!/kdenlive/agent-harness/ +!/shotcut/agent-harness/ +!/anygen/agent-harness/ +!/zoom/agent-harness/ +!/sketch/agent-harness/ +!/drawio/agent-harness/ +!/godot/agent-harness/ +!/mermaid/agent-harness/ +!/comfyui/agent-harness/ +!/dify-workflow/agent-harness/ +!/adguardhome/agent-harness/ +!/novita/agent-harness/ +!/minimax/agent-harness/ +!/ollama/agent-harness/ +!/browser/agent-harness/ +!/web-yu-pri/agent-harness/ +!/seaclip/agent-harness/ +!/pm2/agent-harness/ +!/chromadb/agent-harness/ +!/musescore/agent-harness/ +!/krita/agent-harness/ +!/freecad/agent-harness/ +!/iterm2/agent-harness/ +!/slay_the_spire_ii/agent-harness/ +!/slay_the_spire_ii/README.md +!/eth2-quickstart/agent-harness/ +!/rms/agent-harness/ +!/renderdoc/agent-harness/ +!/cloudcompare/agent-harness/ +!/openscreen/agent-harness/ +!/3MF/agent-harness/ +!/cloudanalyzer/ +!/cloudanalyzer/agent-harness/ +!/wiremock/ +!/wiremock/agent-harness/ +!/exa/agent-harness/ +!/QGIS/agent-harness/ +!/n8n/agent-harness/ +!/obsidian/agent-harness/ +!/joplin/agent-harness/ +!/calibre/agent-harness/ +!/safari/ +!/safari/agent-harness/ +!/unrealinsights/agent-harness/ +!/firefly-iii/agent-harness/ +!/nsight-graphics/agent-harness/ +!/lldb/agent-harness/ +!/macrocli/agent-harness/ +!/sbox/agent-harness/ +!/quietshrink/agent-harness/ +!/mailchimp/agent-harness/ +!/live2d/agent-harness/ +!/rekordbox/agent-harness/ +!/eez-studio/agent-harness/ +!/tigris/agent-harness/ +!/cc-switch/agent-harness/ +!/siyuan/agent-harness/ + +# Exclude non-gedit demo macros from macrocli (local only) +/macrocli/agent-harness/cli_anything/macrocli/macro_definitions/demo/flameshot* +/macrocli/agent-harness/cli_anything/macrocli/macro_definitions/demo/kolourpaint* +/macrocli/agent-harness/cli_anything/macrocli/macro_definitions/demo/snapshots/ + + +# Step 7: Ignore build artifacts within allowed dirs +**/__pycache__/ +**/*.egg-info/ +**/*.pyc +**/dist/ +**/build/ +**/.pytest_cache/ +**/*.egg +**/*.wav +**/*.blend +**/*.xcf +**/*.mlt + +# Step 8: But allow assets images +!/assets/*.png +!/assets/*.jpg + +assets/gen_typing_gif.py +!README_CN.md +!README_DE.md +!opencode-commands/ +!/qoder-plugin/ + +# Step 10: Allow CLI Hub registry and frontend +!/registry.json +!/public_registry.json +!/matrix_registry.json +# docs/* is always ignored — working documents stay local and are never committed. +# (Previously-tracked files under docs/ remain in the index until explicitly removed.) +/docs/ +# CLI-Matrix is unshipped/confidential — never track anything under docs/cli-matrix/. +/docs/cli-matrix/ + +# Build-time vendored matrix skill content (generated by cli-hub setup.py build_py/sdist) +/cli-hub/cli_hub/_matrix_data/ +!/notebooklm/ +/notebooklm/* +/notebooklm/.* +!/notebooklm/agent-harness/ +!/intelwatch/ +!/intelwatch/agent-harness/ +!/jumpserver/ +!/jumpserver/agent-harness/ +!/cli_anything/jumpserver/ diff --git a/.pi-extension/cli-anything/README.md b/.pi-extension/cli-anything/README.md new file mode 100644 index 0000000..cb34957 --- /dev/null +++ b/.pi-extension/cli-anything/README.md @@ -0,0 +1,125 @@ +# CLI-Anything Extension for Pi Coding Agent + +This directory contains the Pi Coding Agent extension for CLI-Anything, enabling AI agents to build powerful, stateful CLI interfaces for any GUI application. + +## Overview + +The CLI-Anything Pi extension provides 5 slash commands that inject the HARNESS.md methodology and command specifications into the agent session. This enables the agent to build CLI harnesses for any software with a codebase. + +## Installation + +### Option 1: Global Install (Recommended) + +Install the extension globally so `/cli-anything` commands are available in **all** Pi projects: + +```bash +cd CLI-Anything +bash .pi-extension/cli-anything/install.sh +``` + +To uninstall: + +```bash +bash .pi-extension/cli-anything/install.sh --uninstall +``` + +### Verify + +After installing, run `/reload` in Pi or restart Pi. Then type `/cli-anything` to verify the command is available. + +## Commands + +| Command | Description | +|---------|-------------| +| `/cli-anything ` | Build a complete CLI harness for any GUI application | +| `/cli-anything:refine [focus]` | Refine an existing CLI harness to improve coverage | +| `/cli-anything:test ` | Run tests for a CLI harness and update TEST.md | +| `/cli-anything:validate ` | Validate a CLI harness against HARNESS.md standards | +| `/cli-anything:list [options]` | List all CLI-Anything tools (installed and generated) | + +## Usage Examples + +### Build a CLI for GIMP + +``` +/cli-anything ./gimp +``` + +### Build from a GitHub repository + +``` +/cli-anything https://github.com/blender/blender +``` + +### Refine an existing harness + +``` +/cli-anything:refine ./gimp "batch processing and filters" +``` + +### List all installed CLIs + +``` +/cli-anything:list +``` + +## Extension Structure + +``` +.pi-extension/cli-anything/ +├── index.ts # Main extension entry point +├── install.sh # Global installation script +├── README.md # This file +└── tests/ + └── test_extension.test.ts # Command registration tests +``` + +Tests for `skill_generator.py` live in `cli-anything-plugin/tests/` (next to the source). + +> **Note:** Command specs, guides, scripts (skill_generator.py, repl_skin.py), +> templates, and HARNESS.md live in `cli-anything-plugin/` (canonical source). +> `install.sh` copies them into `~/.pi/agent/extensions/cli-anything/` alongside +> `index.ts` at install time. The extension reads them from its own directory +> via `__dirname`. + +## How It Works + +1. **Command Registration**: The extension registers 5 slash commands with Pi's Extension API +2. **Context Injection**: When a command is invoked, it reads HARNESS.md and the relevant command spec +3. **Message Construction**: Builds a comprehensive message with methodology, specs, and user arguments +4. **Agent Execution**: Injects the message into the agent session via `pi.sendUserMessage()` +5. **Path Remapping**: Automatically remaps container paths to local system paths + +## Path Remapping + +The extension handles path remapping between the containerized environment (referenced in HARNESS.md) and the local system: + +| Container Path | Local Path | +|----------------|------------| +| `/root/cli-anything//` | Current working directory | +| `cli-anything-plugin/repl_skin.py` | Resolved from `cli-anything-plugin/` (single source of truth) | +| `~/.claude/plugins/cli-anything/` | `/` | + +## Development + +To modify or extend this extension: + +1. Edit `index.ts` for command behavior changes +2. Edit files in `commands/` for command specification changes +3. Edit `cli-anything-plugin/HARNESS.md` for methodology changes (the canonical source) +4. Edit `guides/` for implementation guide changes + +## Dependencies + +- `@mariozechner/pi-coding-agent` - Pi Extension API +- Node.js built-in modules: `fs`, `path`, `url` + +## License + +Apache License 2.0 - See the main CLI-Anything repository for full license details. + +## See Also + +- [CLI-Anything Main Repository](https://github.com/HKUDS/CLI-Anything) +- [CLI-Hub](https://hkuds.github.io/CLI-Anything/) - Browse all community CLIs +- [CONTRIBUTING.md](../../CONTRIBUTING.md) - Contribution guidelines diff --git a/.pi-extension/cli-anything/index.ts b/.pi-extension/cli-anything/index.ts new file mode 100644 index 0000000..c1cd422 --- /dev/null +++ b/.pi-extension/cli-anything/index.ts @@ -0,0 +1,186 @@ +/** + * CLI-Anything Extension for Pi Coding Agent + * + * Provides 5 slash commands that inject HARNESS.md methodology + command specs + * into the agent session via pi.sendUserMessage(), enabling the agent to build + * CLI harnesses for any GUI application. + * + * Commands: + * /cli-anything - Build a complete CLI harness + * /cli-anything:refine [focus] - Refine an existing CLI harness + * /cli-anything:test - Run tests for a CLI harness + * /cli-anything:validate - Validate a CLI harness + * /cli-anything:list [options] - List all CLI-Anything tools + * + * Asset files are self-contained in the extension directory. + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +// Resolve extension directory for asset loading +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +/** + * Read an asset file relative to the extension directory. + * All assets must be present alongside index.ts (via install.sh or manual copy). + */ +function readAsset(...paths: string[]): string { + const fullPath = join(__dirname, ...paths); + try { + return readFileSync(fullPath, "utf-8"); + } catch (err) { + throw new Error( + `CLI-Anything extension: failed to read asset "${join(...paths)}" at "${fullPath}": ${err instanceof Error ? err.message : String(err)}` + ); + } +} + +/** + * Construct the message payload injected into the agent session. + * Bundles HARNESS.md + command spec + user args into a single user message. + */ +function buildCommandMessage( + commandName: string, + commandMd: string, + userArgs: string, +): string { + const harnessMd = readAsset("HARNESS.md"); + + const guidesDir = join(__dirname, "guides"); + const scriptsDir = join(__dirname, "scripts"); + const templatesDir = join(__dirname, "templates"); + + return `[CLI-Anything Command: ${commandName}] + +## CRITICAL: HARNESS.md — Read First +${harnessMd} + +## Command Specification: ${commandName} +${commandMd} + +## User Arguments +\`${userArgs}\` + +## Extension Asset Paths +The following resources are available on this system. Use the \`read\` tool to access them when needed: +- Guides directory: \`${guidesDir}/\` — when HARNESS.md references guides (e.g. "See guides/session-locking.md"), read them from here +- Scripts directory: \`${scriptsDir}/\` — contains \`skill_generator.py\` +- Templates directory: \`${templatesDir}/\` — contains \`SKILL.md.template\` + +## Path Remapping Rules +The command specs and HARNESS.md were written for a containerized environment. Apply these remapping rules: +1. \`/root/cli-anything//\` → use the current working directory (\`cwd\`). The software source is wherever the user specified in the arguments. +2. \`cli-anything-plugin/repl_skin.py\` → use \`${scriptsDir}/repl_skin.py\` +3. \`cli-anything-plugin/skill_generator.py\` → use \`${scriptsDir}/skill_generator.py\` +4. \`~/.claude/plugins/cli-anything/\` → use \`${__dirname}/\` +5. All relative paths in HARNESS.md (e.g. \`guides/...\`, \`templates/...\`) resolve against the asset paths above, NOT the working directory. + +--- + +You are executing the /${commandName} command. Follow the HARNESS.md methodology and command specification precisely. Read HARNESS.md FIRST before taking any action. When you encounter references to guides, scripts, or templates, read them from the directories listed above. Apply the Path Remapping Rules for any hardcoded paths found in the specs.`; +} + +/** + * Inject command context into the agent session via sendUserMessage. + * This triggers a full agent turn with access to all tools. + */ +function injectCommandContext( + pi: ExtensionAPI, + commandName: string, + commandMdPath: string, + userArgs: string, +): void { + const commandMd = readAsset("commands", commandMdPath); + const message = buildCommandMessage(commandName, commandMd, userArgs); + pi.sendUserMessage(message); +} + +export default function cliAnythingExtension(pi: ExtensionAPI) { + // ─── /cli-anything ───────────────────────────────── + pi.registerCommand("cli-anything", { + description: "Build a complete CLI harness for any GUI application", + handler: async (args, ctx) => { + const trimmed = args.trim(); + if (!trimmed) { + ctx.ui.notify( + "Usage: /cli-anything \n\nProvide a local path to software source code or a GitHub repository URL.", + "warning", + ); + return; + } + + injectCommandContext(pi, "cli-anything", "cli-anything.md", trimmed); + }, + }); + + // ─── /cli-anything:refine [focus] ────────────────────────── + pi.registerCommand("cli-anything:refine", { + description: "Refine an existing CLI harness to improve coverage", + handler: async (args, ctx) => { + const trimmed = args.trim(); + if (!trimmed) { + ctx.ui.notify( + 'Usage: /cli-anything:refine [focus]\n\nExample: /cli-anything:refine /home/user/gimp "batch processing filters"', + "warning", + ); + return; + } + + injectCommandContext(pi, "cli-anything:refine", "refine.md", trimmed); + }, + }); + + // ─── /cli-anything:test ──────────────────────────── + pi.registerCommand("cli-anything:test", { + description: "Run tests for a CLI harness and update TEST.md", + handler: async (args, ctx) => { + const trimmed = args.trim(); + if (!trimmed) { + ctx.ui.notify( + "Usage: /cli-anything:test \n\nProvide a local path to software source code or a GitHub repository URL.", + "warning", + ); + return; + } + + injectCommandContext(pi, "cli-anything:test", "test.md", trimmed); + }, + }); + + // ─── /cli-anything:validate ──────────────────────── + pi.registerCommand("cli-anything:validate", { + description: "Validate a CLI harness against HARNESS.md standards", + handler: async (args, ctx) => { + const trimmed = args.trim(); + if (!trimmed) { + ctx.ui.notify( + "Usage: /cli-anything:validate \n\nProvide a local path to software source code or a GitHub repository URL.", + "warning", + ); + return; + } + + injectCommandContext(pi, "cli-anything:validate", "validate.md", trimmed); + }, + }); + + // ─── /cli-anything:list [--path] [--depth] [--json] ─────────────── + pi.registerCommand("cli-anything:list", { + description: "List all CLI-Anything tools (installed and generated)", + getArgumentCompletions: (prefix: string) => { + const flags = ["--json", "--path ", "--depth "]; + const filtered = flags.filter((f) => f.startsWith(prefix)); + return filtered.length > 0 ? filtered.map((f) => ({ value: f, label: f })) : null; + }, + handler: async (args, ctx) => { + // Parse optional flags, pass everything to the agent + const trimmed = args.trim(); + // No validation needed — the agent handles --path, --depth, --json parsing + injectCommandContext(pi, "cli-anything:list", "list.md", trimmed || "(no arguments — scan current directory)"); + }, + }); +} diff --git a/.pi-extension/cli-anything/install.sh b/.pi-extension/cli-anything/install.sh new file mode 100755 index 0000000..4c6fdde --- /dev/null +++ b/.pi-extension/cli-anything/install.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# install.sh — Install CLI-Anything extension for Pi Coding Agent globally. +# +# Copies the extension into Pi's global extensions directory so the +# /cli-anything commands are available in ALL projects. +# +# Usage: +# bash install.sh # Install +# bash install.sh --uninstall # Uninstall +# +# After installing, run '/reload' in Pi or restart Pi to activate. + +set -euo pipefail + +# ─── Paths ───────────────────────────────────────────────────────────── + +TARGET_DIR="$HOME/.pi/agent/extensions/cli-anything" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# Find repo root reliably — use git, fall back to searching upward +REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null)" || { + dir="$SCRIPT_DIR" + while [ "$dir" != "/" ]; do + if [ -f "$dir/.git" ] || [ -d "$dir/.git" ] || [ -f "$dir/CONTRIBUTING.md" ]; then + REPO_ROOT="$dir" + break + fi + dir="$(dirname "$dir")" + done + if [ -z "${REPO_ROOT:-}" ]; then + echo "Error: Cannot determine repo root. Run this script from inside the CLI-Anything repository." + exit 1 + fi +} + +# ─── Uninstall ───────────────────────────────────────────────────────── + +if [ "${1:-}" = "--uninstall" ]; then + if [ -d "$TARGET_DIR" ]; then + rm -rf "$TARGET_DIR" + echo "✓ CLI-Anything extension uninstalled from $TARGET_DIR" + else + echo "Extension not found at $TARGET_DIR (already uninstalled)" + fi + exit 0 +fi + +# ─── Pre-flight checks ──────────────────────────────────────────────── + +if [ ! -f "$SCRIPT_DIR/index.ts" ]; then + echo "Error: Cannot find index.ts in $SCRIPT_DIR" + echo "Make sure you're running this script from the extension directory." + exit 1 +fi + +HARNESS_SRC="$REPO_ROOT/cli-anything-plugin/HARNESS.md" +if [ ! -f "$HARNESS_SRC" ]; then + echo "Warning: HARNESS.md not found at $HARNESS_SRC" + echo "The extension will still be installed but may not function correctly." + echo "" +fi + +# ─── Install ─────────────────────────────────────────────────────────── + +echo "Installing CLI-Anything extension for Pi Coding Agent..." +echo "" + +# Create target directories +mkdir -p "$TARGET_DIR/commands" +mkdir -p "$TARGET_DIR/guides" +mkdir -p "$TARGET_DIR/scripts" +mkdir -p "$TARGET_DIR/templates" + +# Copy extension entry point +cp "$SCRIPT_DIR/index.ts" "$TARGET_DIR/" + +# Copy command specifications from the canonical location +COMMANDS_SRC="$REPO_ROOT/cli-anything-plugin/commands" +if [ -d "$COMMANDS_SRC" ]; then + cp "$COMMANDS_SRC/"*.md "$TARGET_DIR/commands/" + echo "✓ commands copied from $COMMANDS_SRC" +fi +# Copy guides from the canonical location +GUIDES_SRC="$REPO_ROOT/cli-anything-plugin/guides" +if [ -d "$GUIDES_SRC" ]; then + cp "$GUIDES_SRC/"*.md "$TARGET_DIR/guides/" + echo "✓ guides copied from $GUIDES_SRC" +fi + +# Copy templates from the canonical location +TEMPLATES_SRC="$REPO_ROOT/cli-anything-plugin/templates" +if [ -d "$TEMPLATES_SRC" ]; then + cp "$TEMPLATES_SRC/"* "$TARGET_DIR/templates/" + echo "✓ templates copied from $TEMPLATES_SRC" +fi + +# Copy HARNESS.md from the canonical location (so the extension can find it locally) +if [ -f "$HARNESS_SRC" ]; then + cp "$HARNESS_SRC" "$TARGET_DIR/HARNESS.md" + echo "✓ HARNESS.md copied from $HARNESS_SRC" +fi + +# Copy repl_skin.py from the canonical location +REPL_SKIN_SRC="$REPO_ROOT/cli-anything-plugin/repl_skin.py" +if [ -f "$REPL_SKIN_SRC" ]; then + cp "$REPL_SKIN_SRC" "$TARGET_DIR/scripts/repl_skin.py" + echo "✓ repl_skin.py copied from $REPL_SKIN_SRC" +fi + +# Copy skill_generator.py from the canonical location +SKILL_GEN_SRC="$REPO_ROOT/cli-anything-plugin/skill_generator.py" +if [ -f "$SKILL_GEN_SRC" ]; then + cp "$SKILL_GEN_SRC" "$TARGET_DIR/scripts/skill_generator.py" + echo "✓ skill_generator.py copied from $SKILL_GEN_SRC" +fi + +# Copy tests from the canonical location +TESTS_SRC="$REPO_ROOT/cli-anything-plugin/tests" +if [ -d "$TESTS_SRC" ]; then + mkdir -p "$TARGET_DIR/tests" + cp "$TESTS_SRC"/*.py "$TARGET_DIR/tests/" + echo "✓ tests copied from $TESTS_SRC" +fi + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo " ✓ CLI-Anything extension installed globally!" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" +echo " Location: $TARGET_DIR" +echo "" +echo " Available commands:" +echo " /cli-anything Build a CLI harness" +echo " /cli-anything:refine [focus] Refine a harness" +echo " /cli-anything:test Test a harness" +echo " /cli-anything:validate Validate a harness" +echo " /cli-anything:list [options] List all CLI tools" +echo "" +echo " Run '/reload' in Pi or restart Pi to activate." +echo "" diff --git a/.pi-extension/cli-anything/tests/test_extension.test.ts b/.pi-extension/cli-anything/tests/test_extension.test.ts new file mode 100644 index 0000000..eb3b300 --- /dev/null +++ b/.pi-extension/cli-anything/tests/test_extension.test.ts @@ -0,0 +1,285 @@ +/** + * Tests for CLI-Anything Pi extension command registration. + * + * Verifies that all 5 commands are registered, handlers invoke + * sendUserMessage with the expected content, and edge cases are handled. + * + * Run with: npx vitest run tests/test_extension.test.ts + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// ─── Mock Pi Extension API ──────────────────────────────────────────── + +function createMockPi() { + const registeredCommands: Array<{ + name: string; + options: { description: string; handler: Function }; + }> = []; + + const sentMessages: string[] = []; + + return { + registerCommand: vi.fn((name: string, options: any) => { + registeredCommands.push({ name, options }); + }), + sendUserMessage: vi.fn((msg: string) => { + sentMessages.push(msg); + }), + registeredCommands, + sentMessages, + }; +} + +// ─── Mock file system for readAsset ─────────────────────────────────── +// +// The extension reads assets from __dirname via readFileSync. +// We mock node:fs so that readAsset returns predictable content regardless +// of where the test is running (CI, local, etc.). + +const MOCK_HARNESS = "# HARNESS.md Mock\nTest harness content."; +const MOCK_COMMANDS: Record = { + "cli-anything.md": "# cli-anything command mock", + "refine.md": "# refine command mock", + "test.md": "# test command mock", + "validate.md": "# validate command mock", + "list.md": "# list command mock", +}; + +vi.mock("node:fs", () => ({ + readFileSync: (path: string, encoding: string) => { + const p = String(path); + if (p.endsWith("HARNESS.md")) return MOCK_HARNESS; + for (const [name, content] of Object.entries(MOCK_COMMANDS)) { + if (p.endsWith(join("commands", name))) return content; + } + throw new Error(`Mock readFileSync: file not found: ${p}`); + }, +})); + +// ─── Tests ──────────────────────────────────────────────────────────── + +describe("CLI-Anything Extension", () => { + let mockPi: ReturnType; + + beforeEach(() => { + mockPi = createMockPi(); + }); + + async function loadExtension() { + // Bust module cache so each test gets a fresh import + const extPath = join(__dirname, "..", "index.ts") + "?t=" + Date.now(); + const mod = await import(extPath); + return mod; + } + + it("should export a default function", async () => { + const mod = await loadExtension(); + expect(typeof mod.default).toBe("function"); + }); + + it("should register exactly 5 commands", async () => { + const mod = await loadExtension(); + mod.default(mockPi); + expect(mockPi.registerCommand).toHaveBeenCalledTimes(5); + }); + + it("should register all expected command names", async () => { + const mod = await loadExtension(); + mod.default(mockPi); + + const names = mockPi.registeredCommands.map((c) => c.name); + expect(names).toContain("cli-anything"); + expect(names).toContain("cli-anything:refine"); + expect(names).toContain("cli-anything:test"); + expect(names).toContain("cli-anything:validate"); + expect(names).toContain("cli-anything:list"); + }); + + it("each command should have a description and handler", async () => { + const mod = await loadExtension(); + mod.default(mockPi); + + for (const cmd of mockPi.registeredCommands) { + expect(typeof cmd.options.description).toBe("string"); + expect(cmd.options.description.length).toBeGreaterThan(0); + expect(typeof cmd.options.handler).toBe("function"); + } + }); + + it("should send user message with HARNESS.md, command spec, and user args", async () => { + const mod = await loadExtension(); + mod.default(mockPi); + + const cmd = mockPi.registeredCommands.find( + (c) => c.name === "cli-anything", + ); + expect(cmd).toBeDefined(); + + const mockCtx = { ui: { notify: vi.fn() } }; + await cmd!.options.handler(" /path/to/software", mockCtx); + + expect(mockPi.sendUserMessage).toHaveBeenCalledTimes(1); + const msg = mockPi.sentMessages[0]; + expect(msg).toContain("[CLI-Anything Command: cli-anything]"); + expect(msg).toContain(MOCK_HARNESS); + expect(msg).toContain("# cli-anything command mock"); + expect(msg).toContain("/path/to/software"); + expect(msg).toContain("Extension Asset Paths"); + expect(msg).toContain("Path Remapping Rules"); + }); + + it("should show warning when /cli-anything is invoked without args", async () => { + const mod = await loadExtension(); + mod.default(mockPi); + + const cmd = mockPi.registeredCommands.find( + (c) => c.name === "cli-anything", + ); + + const mockNotify = vi.fn(); + const mockCtx = { ui: { notify: mockNotify } }; + await cmd!.options.handler(" ", mockCtx); + + expect(mockNotify).toHaveBeenCalledTimes(1); + expect(mockNotify).toHaveBeenCalledWith( + expect.stringContaining("Usage: /cli-anything"), + "warning", + ); + expect(mockPi.sendUserMessage).not.toHaveBeenCalled(); + }); + + it("should show warning when /cli-anything:refine is invoked without args", async () => { + const mod = await loadExtension(); + mod.default(mockPi); + + const cmd = mockPi.registeredCommands.find( + (c) => c.name === "cli-anything:refine", + ); + + const mockNotify = vi.fn(); + const mockCtx = { ui: { notify: mockNotify } }; + await cmd!.options.handler("", mockCtx); + + expect(mockNotify).toHaveBeenCalledTimes(1); + expect(mockNotify).toHaveBeenCalledWith( + expect.stringContaining("Usage: /cli-anything:refine"), + "warning", + ); + expect(mockPi.sendUserMessage).not.toHaveBeenCalled(); + }); + + it("should show warning when /cli-anything:test is invoked without args", async () => { + const mod = await loadExtension(); + mod.default(mockPi); + + const cmd = mockPi.registeredCommands.find( + (c) => c.name === "cli-anything:test", + ); + + const mockNotify = vi.fn(); + const mockCtx = { ui: { notify: mockNotify } }; + await cmd!.options.handler(" ", mockCtx); + + expect(mockNotify).toHaveBeenCalledTimes(1); + expect(mockNotify).toHaveBeenCalledWith( + expect.stringContaining("Usage: /cli-anything:test"), + "warning", + ); + expect(mockPi.sendUserMessage).not.toHaveBeenCalled(); + }); + + it("should show warning when /cli-anything:validate is invoked without args", async () => { + const mod = await loadExtension(); + mod.default(mockPi); + + const cmd = mockPi.registeredCommands.find( + (c) => c.name === "cli-anything:validate", + ); + + const mockNotify = vi.fn(); + const mockCtx = { ui: { notify: mockNotify } }; + await cmd!.options.handler("", mockCtx); + + expect(mockNotify).toHaveBeenCalledTimes(1); + expect(mockNotify).toHaveBeenCalledWith( + expect.stringContaining("Usage: /cli-anything:validate"), + "warning", + ); + expect(mockPi.sendUserMessage).not.toHaveBeenCalled(); + }); + + it("/cli-anything:list should work with no arguments", async () => { + const mod = await loadExtension(); + mod.default(mockPi); + + const cmd = mockPi.registeredCommands.find( + (c) => c.name === "cli-anything:list", + ); + + const mockCtx = { ui: { notify: vi.fn() } }; + await cmd!.options.handler("", mockCtx); + + expect(mockPi.sendUserMessage).toHaveBeenCalledTimes(1); + const msg = mockPi.sentMessages[0]; + expect(msg).toContain("[CLI-Anything Command: cli-anything:list]"); + expect(msg).toContain("(no arguments"); + }); + + it("/cli-anything:list should pass flags through", async () => { + const mod = await loadExtension(); + mod.default(mockPi); + + const cmd = mockPi.registeredCommands.find( + (c) => c.name === "cli-anything:list", + ); + + const mockCtx = { ui: { notify: vi.fn() } }; + await cmd!.options.handler("--json --depth 2", mockCtx); + + expect(mockPi.sendUserMessage).toHaveBeenCalledTimes(1); + const msg = mockPi.sentMessages[0]; + expect(msg).toContain("--json --depth 2"); + }); + + it("/cli-anything:list getArgumentCompletions should return matching flags", async () => { + const mod = await loadExtension(); + mod.default(mockPi); + + const cmd = mockPi.registeredCommands.find( + (c) => c.name === "cli-anything:list", + ); + + const completions = cmd!.options.getArgumentCompletions("--j"); + expect(completions).toEqual([{ value: "--json", label: "--json" }]); + }); + + it("/cli-anything:list getArgumentCompletions should return null for unknown prefix", async () => { + const mod = await loadExtension(); + mod.default(mockPi); + + const cmd = mockPi.registeredCommands.find( + (c) => c.name === "cli-anything:list", + ); + + const completions = cmd!.options.getArgumentCompletions("--unknown"); + expect(completions).toBeNull(); + }); + + it("readAsset should throw descriptive error for missing file", async () => { + const mod = await loadExtension(); + mod.default(mockPi); + + // Invoke with a valid arg — but our mock only has known files, + // so any command that reads assets should succeed. We test error + // by checking the error message format matches what readAsset produces. + // The mock throws for unknown files, simulating a real missing asset. + const { readFileSync } = await import("node:fs"); + expect(() => readFileSync("/nonexistent/file.md", "utf-8")).toThrow(); + }); +}); diff --git a/3MF/agent-harness/3MF.md b/3MF/agent-harness/3MF.md new file mode 100644 index 0000000..e3a7e05 --- /dev/null +++ b/3MF/agent-harness/3MF.md @@ -0,0 +1,71 @@ +# 3MF CLI — Standard Operating Procedure + +## Overview + +The **3MF CLI** (`cli-anything-3mf`) is an agent-native command-line interface for +inspecting, modifying, and repairing 3MF mesh files used in 3D printing. + +Unlike traditional 3D-printing CLI tools (PrusaSlicer CLI, BambuStudio CLI, admesh) +that treat meshes as opaque triangle soups, this tool provides **feature-aware +geometry editing** — it can detect cylindrical holes, measure their diameters, and +resize them via CLI commands. + +## 3MF Format + +A 3MF file is a ZIP archive containing: +- `[Content_Types].xml` — MIME type declarations +- `_rels/.rels` — relationship definitions +- `3D/3dmodel.model` — XML mesh data (core spec namespace: `http://schemas.microsoft.com/3dmanufacturing/core/2015/02`) +- `Metadata/` — slicer settings, thumbnails, plate configurations (BambuStudio, PrusaSlicer) + +## Architecture + +``` +threemf_cli.py Click CLI + REPL entry point + | + +-- core/parser.py 3MF ZIP/XML parsing, lossless repack + +-- core/inspector.py Cross-section hole detection + +-- core/modifier.py Radial vertex scaling for hole resize + +-- core/repair.py Degenerate face removal, vertex merge + | + +-- utils/threemf_backend.py trimesh/numpy geometry utilities + +-- utils/repl_skin.py Unified REPL styling +``` + +## Core Algorithm + +### Hole Detection (inspector.py) +1. Take N cross-sections perpendicular to the hole axis +2. At each section, identify circular entities via trimesh path analysis +3. Fit circles using least-squares (Kasa method) +4. Group circles across sections by center proximity +5. Calculate confidence from detection consistency + +### Hole Resize (modifier.py) +1. Re-detect holes to get current geometry +2. Find wall vertices at known radii from hole axis +3. Scale vertices radially: `new_pos = center + direction * (new_r / old_r)` +4. Fix degenerate faces caused by coincident vertices + +### Mesh Repair (repair.py) +1. Merge duplicate vertices (coordinate rounding + dedup) +2. Remove degenerate triangles (collapsed faces) +3. Remove unreferenced vertices +4. Fix face winding via trimesh + +## Slicer Compatibility + +The tool preserves all non-mesh content during repack: +- BambuStudio: project_settings.config, model_settings.config, plate thumbnails +- PrusaSlicer: slic3r_pe configs, layer height profiles + +Mesh triangle attributes are also preserved when the corresponding triangle is +kept. This includes material/property attributes such as `pid`, `p1`, `p2`, and +`p3`, plus unknown vendor attributes on `` elements. + +Component-only objects and build/component `transform` attributes are preserved +as untouched XML when writing files loaded from an existing 3MF archive. The +current geometry operations do not resolve component transforms into mesh +coordinates and cannot edit component-instance transforms directly. + +Output files can be reopened in the original slicer without configuration loss. diff --git a/3MF/agent-harness/cli_anything/threemf/README.md b/3MF/agent-harness/cli_anything/threemf/README.md new file mode 100644 index 0000000..edf41e9 --- /dev/null +++ b/3MF/agent-harness/cli_anything/threemf/README.md @@ -0,0 +1,104 @@ +# cli-anything-3mf + +**3MF Mesh Geometry Editor** — Detect and resize cylindrical holes, repair meshes, compare 3D printing files. + +Part of the [CLI-Anything](https://github.com/HKUDS/CLI-Anything) ecosystem. + +## Installation + +```bash +pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=3MF/agent-harness +``` + +Or for development: + +```bash +cd 3MF/agent-harness +pip install -e ".[dev]" +``` + +## Quick Start + +```bash +# Show mesh info +cli-anything-3mf info model.3mf + +# Detect cylindrical holes +cli-anything-3mf inspect model.3mf + +# Resize holes 0,1,2,3 to 4.2mm diameter +cli-anything-3mf resize model.3mf --hole 0 --hole 1 --hole 2 --hole 3 --diameter 4.2 -o output.3mf + +# Repair mesh issues +cli-anything-3mf repair model.3mf -o repaired.3mf + +# Compare two files +cli-anything-3mf compare original.3mf modified.3mf + +# Interactive REPL +cli-anything-3mf +``` + +## Commands + +| Command | Description | +|---------|-------------| +| `info ` | Show mesh statistics (vertices, faces, bounds, watertight, volume) | +| `inspect ` | Detect and list all cylindrical holes with diameter and position | +| `resize ` | Resize specified holes to a target diameter | +| `repair ` | Fix degenerate faces, duplicate vertices, and normals | +| `compare ` | Side-by-side comparison of two 3MF files | + +All commands support `--json` for machine-readable output. + +## How It Works + +### Hole Detection +The tool takes cross-sections at multiple planes perpendicular to the hole axis, +identifies circular features via least-squares circle fitting, and groups them +across planes to detect consistent cylindrical holes. + +### Hole Resizing +Wall vertices are identified by their radial distance from the hole axis. +Vertices are then scaled radially to achieve the target diameter while preserving +mesh topology and angular position. + +### Slicer Compatibility +All non-mesh content (slicer settings, thumbnails, plate configurations) is +preserved during file repack. Output files can be reopened in BambuStudio or +PrusaSlicer without configuration loss. + +Triangle-level mesh attributes are preserved for triangles that survive an edit, +including `pid`, `p1`, `p2`, `p3`, and unknown vendor attributes. Component-only +objects and component/build `transform` attributes are kept as original XML, but +this CLI does not currently apply or edit component-instance transforms. + +## Architecture + +``` +cli_anything/threemf/ +├── threemf_cli.py # Click CLI + REPL +├── core/ +│ ├── parser.py # 3MF ZIP/XML parsing +│ ├── inspector.py # Cross-section hole detection +│ ├── modifier.py # Hole resizing +│ └── repair.py # Mesh repair +├── utils/ +│ ├── threemf_backend.py # Geometry utilities (trimesh/numpy) +│ └── repl_skin.py # Unified REPL styling +├── skills/ +│ └── SKILL.md # Agent-discoverable skill +└── tests/ + ├── test_core.py # Unit tests (74+) + └── test_full_e2e.py # E2E tests (30+) +``` + +## Dependencies + +- Python 3.10+ +- numpy, scipy, trimesh +- click, prompt-toolkit + +## License + +MIT — See [CLI-Anything LICENSE](https://github.com/HKUDS/CLI-Anything/blob/main/LICENSE) diff --git a/3MF/agent-harness/cli_anything/threemf/__init__.py b/3MF/agent-harness/cli_anything/threemf/__init__.py new file mode 100644 index 0000000..5e23628 --- /dev/null +++ b/3MF/agent-harness/cli_anything/threemf/__init__.py @@ -0,0 +1 @@ +"""3MF CLI - Mesh geometry editor for 3D printing files.""" diff --git a/3MF/agent-harness/cli_anything/threemf/__main__.py b/3MF/agent-harness/cli_anything/threemf/__main__.py new file mode 100644 index 0000000..a47649a --- /dev/null +++ b/3MF/agent-harness/cli_anything/threemf/__main__.py @@ -0,0 +1,3 @@ +"""Allow running as python -m cli_anything.threemf""" +from cli_anything.threemf.threemf_cli import main +main() diff --git a/3MF/agent-harness/cli_anything/threemf/core/__init__.py b/3MF/agent-harness/cli_anything/threemf/core/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/3MF/agent-harness/cli_anything/threemf/core/__init__.py @@ -0,0 +1 @@ + diff --git a/3MF/agent-harness/cli_anything/threemf/core/inspector.py b/3MF/agent-harness/cli_anything/threemf/core/inspector.py new file mode 100644 index 0000000..d94b828 --- /dev/null +++ b/3MF/agent-harness/cli_anything/threemf/core/inspector.py @@ -0,0 +1,406 @@ +"""Hole detection -- cross-section analysis to find cylindrical holes in meshes.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np +from scipy.cluster.hierarchy import fcluster, linkage + +from cli_anything.threemf.core.parser import MeshData, ThreeMFData +from cli_anything.threemf.utils import threemf_backend as backend + +# --------------------------------------------------------------------------- +# Data classes +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class DetectedHole: + """A cylindrical hole detected in a mesh.""" + + hole_id: int + center: tuple[float, float] # (coord1, coord2) perpendicular to axis + diameter: float + axis_min: float # extent along axis + axis_max: float + axis: int # 0=X, 1=Y, 2=Z + confidence: float # 0.0-1.0 + vertex_count: int + + +@dataclass(frozen=True) +class InspectParams: + """Parameters that control hole-detection behaviour.""" + + num_planes: int = 20 + min_hole_diameter: float = 0.5 + min_confidence: float = 0.7 + axis: int = 0 # which axis to section perpendicular to + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_GROUP_DISTANCE_MM = 0.5 # max (centre, radius) distance to merge circles into one hole + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def inspect_mesh( + mesh_data: MeshData, + params: InspectParams | None = None, +) -> list[DetectedHole]: + """Detect cylindrical holes via multi-plane cross-section analysis. + + Algorithm + --------- + 1. Take cross-sections at ``num_planes`` evenly-spaced levels along *axis*. + 2. At each section call ``cross_section_circles()`` to find circular features. + 3. Group circles across planes by centre proximity (hierarchical clustering + with a 0.5 mm distance threshold). + 4. For each group compute the average diameter, axis extent, and confidence. + 5. Filter by *min_hole_diameter* and *min_confidence*. + 6. Count vertices on each hole wall. + 7. Return a sorted list of ``DetectedHole`` (sorted by hole_id). + """ + + if params is None: + params = InspectParams() + + _validate_mesh(mesh_data) + + vertices = mesh_data.vertices + if vertices.shape[0] == 0: + return [] + + axis = params.axis + + # Step 1 -- basic axis range check + perp_axes = _perpendicular_axes(axis) + axis_vals = vertices[:, axis] + axis_lo, axis_hi = float(np.min(axis_vals)), float(np.max(axis_vals)) + if axis_hi - axis_lo < 1e-9: + return [] + + # Step 2 -- collect circles via multi-plane cross-section + raw_circles = backend.cross_section_circles( + mesh_data, axis=axis, num_planes=params.num_planes, + ) + all_circles = [] + for c in raw_circles: + all_circles.append({ + "center": (float(c["center"][0]), float(c["center"][1])), + "radius": float(c["radius"]), + "fit_error": float(c.get("fit_error", 0.0)), + "level": float(c["plane_value"]), + }) + if not all_circles: + return [] + + # Step 3 -- group circles by centre proximity via hierarchical clustering + groups = _group_circles(all_circles) + + # Step 4/5 -- build candidate holes, compute confidence, filter + num_planes = params.num_planes + holes: list[DetectedHole] = [] + hole_id = 0 + hole_mesh = backend.mesh_to_trimesh(mesh_data) if groups else None + + for group in groups: + radii = np.array([c["radius"] for c in group]) + mean_radius = float(np.mean(radii)) + mean_diameter = mean_radius * 2.0 + + if mean_diameter < params.min_hole_diameter: + continue + + # Confidence = (planes_with_detection / total_planes) * (1 - mean_fit_error / radius) + fit_errors = np.array([c["fit_error"] for c in group]) + mean_fit_error = float(np.mean(fit_errors)) + detection_ratio = len(group) / num_planes + if mean_radius > 0: + quality = 1.0 - mean_fit_error / mean_radius + else: + quality = 0.0 + confidence = float(np.clip(detection_ratio * quality, 0.0, 1.0)) + + if confidence < params.min_confidence: + continue + + # Centre (average across all detections) + centres = np.array([c["center"] for c in group]) + avg_center = (float(np.mean(centres[:, 0])), float(np.mean(centres[:, 1]))) + + # Axis extent -- measured from the hole's actual wall vertices. The + # cross-section planes are inset from the mesh bounds, so their levels + # under-report a through hole whose wall vertices sit exactly on the + # part faces; that gap made resize's axial band miss the rim vertices + # and move nothing. We therefore read the extent from real wall + # vertices, but only within this group's detected span extended by one + # sampling inset (clamped to the mesh) -- so we recover the rims without + # letting an unrelated coaxial same-radius feature elsewhere along the + # axis stretch the extent. Fall back to plane levels if none match. + group_levels = [c["level"] for c in group] + level_min, level_max = float(min(group_levels)), float(max(group_levels)) + inset = (axis_hi - axis_lo) * backend.PLANE_INSET_FRACTION + search_lo = max(axis_lo, level_min - inset) + search_hi = min(axis_hi, level_max + inset) + wall_min, wall_max = _wall_axial_extent( + vertices, avg_center, mean_radius, axis, perp_axes, search_lo, search_hi, + ) + if wall_min is None: + axis_min, axis_max = level_min, level_max + else: + axis_min, axis_max = wall_min, wall_max + + # Keep only interior holes, not exterior contours. A hole's cylindrical + # wall faces the void, so its surface normals point toward the axis; the + # part's outer boundary, a bare cylinder, or a solid boss all face + # outward. Classifying by wall-normal orientation keeps through *and* + # blind holes while rejecting the exterior circles that splitting groups + # by radius would otherwise surface as resizable holes. + if not _is_interior_hole( + hole_mesh, avg_center, mean_radius, axis, perp_axes, axis_min, axis_max, + ): + continue + + # Step 6 -- count wall vertices + vertex_count = _count_wall_vertices( + vertices, avg_center, mean_radius, axis, perp_axes, axis_min, axis_max, + ) + + holes.append(DetectedHole( + hole_id=hole_id, + center=avg_center, + diameter=round(mean_diameter, 4), + axis_min=round(axis_min, 4), + axis_max=round(axis_max, 4), + axis=axis, + confidence=round(confidence, 4), + vertex_count=vertex_count, + )) + hole_id += 1 + + holes.sort(key=lambda h: h.hole_id) + return holes + + +def get_mesh_info(mesh_data: MeshData) -> dict: + """Return mesh statistics using ``backend.compute_mesh_stats()``.""" + + _validate_mesh(mesh_data) + stats = backend.compute_mesh_stats(mesh_data) + return { + "object_id": mesh_data.object_id, + "name": mesh_data.name, + "num_vertices": int(mesh_data.vertices.shape[0]), + "num_triangles": int(mesh_data.triangles.shape[0]), + **stats, + } + + +def compare_meshes(mesh_a: MeshData, mesh_b: MeshData) -> dict: + """Compare two meshes and return a summary of differences. + + Returns a dict with keys: vertices, faces, volume, watertight — each a + sub-dict with file1/file2/diff (matching the CLI display format). + """ + + _validate_mesh(mesh_a) + _validate_mesh(mesh_b) + + stats_a = backend.compute_mesh_stats(mesh_a) + stats_b = backend.compute_mesh_stats(mesh_b) + + vol_a = stats_a.get("volume_mm3") or 0.0 + vol_b = stats_b.get("volume_mm3") or 0.0 + + return { + "vertices": { + "file1": int(mesh_a.vertices.shape[0]), + "file2": int(mesh_b.vertices.shape[0]), + "diff": int(mesh_b.vertices.shape[0]) - int(mesh_a.vertices.shape[0]), + }, + "faces": { + "file1": int(mesh_a.triangles.shape[0]), + "file2": int(mesh_b.triangles.shape[0]), + "diff": int(mesh_b.triangles.shape[0]) - int(mesh_a.triangles.shape[0]), + }, + "volume": { + "file1": float(vol_a), + "file2": float(vol_b), + "diff": float(vol_b - vol_a), + }, + "watertight": { + "file1": stats_a.get("watertight", False), + "file2": stats_b.get("watertight", False), + }, + } + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _validate_mesh(mesh_data: MeshData) -> None: + """Raise ``ValueError`` for clearly invalid mesh data.""" + if mesh_data is None: + raise ValueError("mesh_data must not be None") + if not isinstance(mesh_data.vertices, np.ndarray): + raise ValueError("mesh_data.vertices must be a numpy ndarray") + if not isinstance(mesh_data.triangles, np.ndarray): + raise ValueError("mesh_data.triangles must be a numpy ndarray") + if mesh_data.vertices.ndim != 2 or mesh_data.vertices.shape[1] != 3: + raise ValueError("mesh_data.vertices must have shape (N, 3)") + if mesh_data.triangles.ndim != 2 or mesh_data.triangles.shape[1] != 3: + if mesh_data.triangles.shape[0] != 0: + raise ValueError("mesh_data.triangles must have shape (M, 3)") + + +def _perpendicular_axes(axis: int) -> tuple[int, int]: + """Return the two axes perpendicular to *axis*.""" + return {0: (1, 2), 1: (0, 2), 2: (0, 1)}[axis] + + +def _group_circles(circles: list[dict]) -> list[list[dict]]: + """Group circles from different planes into distinct holes. + + Circles belonging to the same hole share both a centre **and** a radius + across cross-section planes. Concentric features with different radii -- + e.g. a hole bored through a solid body, whose section also yields the + body's outer contour -- must therefore stay in separate groups; otherwise + their radii get averaged into a meaningless diameter (the inner hole and + outer wall collapse into one bogus circle). We cluster on the + ``(centre_x, centre_y, radius)`` feature vector via scipy hierarchical + clustering with a distance threshold of ``_GROUP_DISTANCE_MM``. + """ + + if len(circles) == 1: + return [circles] + + features = np.array( + [[c["center"][0], c["center"][1], c["radius"]] for c in circles] + ) + link = linkage(features, method="single", metric="euclidean") + labels = fcluster(link, t=_GROUP_DISTANCE_MM, criterion="distance") + + groups: dict[int, list[dict]] = {} + for label, circle in zip(labels, circles): + groups.setdefault(int(label), []).append(circle) + + return list(groups.values()) + + +def _wall_axial_extent( + vertices: np.ndarray, + center: tuple[float, float], + radius: float, + axis: int, + perp_axes: tuple[int, int], + search_lo: float, + search_hi: float, + tolerance: float = 0.06, +) -> tuple[float, float] | tuple[None, None]: + """Return the axial ``(min, max)`` of the hole's wall vertices. + + A vertex counts as wall when its radial distance from the hole axis is + within *tolerance* of *radius* **and** its axial coordinate lies in + ``[search_lo, search_hi]``. The axial window keeps a coaxial same-radius + feature elsewhere on the axis from stretching the extent. Returns + ``(None, None)`` when no wall vertex matches, so the caller can fall back + to the sampled plane levels. + """ + + ax0, ax1 = perp_axes + dx = vertices[:, ax0] - center[0] + dy = vertices[:, ax1] - center[1] + dist = np.sqrt(dx * dx + dy * dy) + axial_all = vertices[:, axis] + on_wall = ( + (np.abs(dist - radius) < tolerance) + & (axial_all >= search_lo) + & (axial_all <= search_hi) + ) + if not np.any(on_wall): + return None, None + axial = axial_all[on_wall] + return float(np.min(axial)), float(np.max(axial)) + + +def _is_interior_hole( + tm, + center: tuple[float, float], + radius: float, + axis: int, + perp_axes: tuple[int, int], + axis_min: float, + axis_max: float, + tolerance: float = 0.06, +) -> bool: + """Whether a detected circle bounds an interior hole rather than an exterior contour. + + A hole's cylindrical wall faces the void, so its outward surface normals + point *toward* the axis; the part's outer boundary, a bare cylinder, or a + solid boss all face *away* from the axis. We classify by the mean radial + component of the wall-face normals -- which keeps through **and** blind holes + (a vertex-enclosure test fails on a blind hole's floor end, where no material + lies beyond the radius). + + Needs consistent winding, which valid 3MF provides. If the winding is + inconsistent or the wall can't be sampled we keep the candidate rather than + risk dropping a real hole. + """ + + if tm is None or not tm.is_winding_consistent: + return True + + ax0, ax1 = perp_axes + centroids = tm.triangles_center + du = centroids[:, ax0] - center[0] + dv = centroids[:, ax1] - center[1] + dist = np.sqrt(du * du + dv * dv) + + band = (centroids[:, axis] >= axis_min - tolerance) & ( + centroids[:, axis] <= axis_max + tolerance + ) + on_wall = band & (np.abs(dist - radius) < max(0.15 * radius, 0.2)) + if not np.any(on_wall): + return True + + safe_dist = np.where(dist > 1e-12, dist, 1.0) + normals = tm.face_normals + radial_dot = normals[:, ax0] * (du / safe_dist) + normals[:, ax1] * (dv / safe_dist) + return bool(np.mean(radial_dot[on_wall]) < 0.0) + + +def _count_wall_vertices( + vertices: np.ndarray, + center: tuple[float, float], + radius: float, + axis: int, + perp_axes: tuple[int, int], + axis_min: float, + axis_max: float, + tolerance: float = 0.06, +) -> int: + """Count vertices that lie on the cylindrical wall of a hole.""" + + ax0, ax1 = perp_axes + in_range = (vertices[:, axis] >= axis_min - tolerance) & ( + vertices[:, axis] <= axis_max + tolerance + ) + subset = vertices[in_range] + if subset.shape[0] == 0: + return 0 + + dx = subset[:, ax0] - center[0] + dy = subset[:, ax1] - center[1] + dist = np.sqrt(dx * dx + dy * dy) + on_wall = np.abs(dist - radius) < tolerance + return int(np.count_nonzero(on_wall)) diff --git a/3MF/agent-harness/cli_anything/threemf/core/modifier.py b/3MF/agent-harness/cli_anything/threemf/core/modifier.py new file mode 100644 index 0000000..a806292 --- /dev/null +++ b/3MF/agent-harness/cli_anything/threemf/core/modifier.py @@ -0,0 +1,235 @@ +"""Hole resizing -- radial vertex scaling to change hole diameters.""" + +from __future__ import annotations + +from dataclasses import replace + +import numpy as np + +from cli_anything.threemf.core.parser import MeshData, ThreeMFData +from cli_anything.threemf.core.inspector import ( + DetectedHole, + InspectParams, + inspect_mesh, +) + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_WALL_RADIUS_TOLERANCE = 0.06 # mm -- proven in earlier 3MF editing session + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def resize_holes( + threemf_data: ThreeMFData, + hole_ids: list[int], + target_diameter: float, + mesh_index: int = 0, + params: InspectParams | None = None, +) -> tuple[ThreeMFData, list[dict]]: + """Resize specified holes to *target_diameter* and return a **new** ``ThreeMFData``. + + Parameters + ---------- + params: + Hole-detection parameters. ``resize_holes`` re-detects the mesh's + holes to resolve *hole_ids* to geometry, so *params* **must match the + parameters used to inspect the mesh** (especially ``axis``) -- the + ``hole_id`` numbering is only meaningful relative to one detection + run. When ``None`` the inspector defaults are used (axis=0/X), which + only find *hole_ids* that ``inspect`` surfaces with those same + defaults. + + Returns + ------- + tuple + ``(new_data, changes)`` where *changes* is a list of per-hole dicts + with keys: hole_id, old_diameter, new_diameter, vertices_moved. + + Raises + ------ + ValueError + If *threemf_data* is ``None``, *mesh_index* is out of range, + *target_diameter* is non-positive, or any *hole_id* is unknown. + """ + + _validate_resize_inputs(threemf_data, hole_ids, target_diameter, mesh_index) + + mesh = threemf_data.meshes[mesh_index] + detected_holes = inspect_mesh(mesh, params) + + detected_ids = {h.hole_id for h in detected_holes} + unknown = set(hole_ids) - detected_ids + if unknown: + raise ValueError( + f"Unknown hole_ids: {sorted(unknown)}. " + f"Detected hole_ids: {sorted(detected_ids)}" + ) + + target_holes = [h for h in detected_holes if h.hole_id in hole_ids] + + current_mesh = mesh + reports: list[dict] = [] + + for hole in target_holes: + current_mesh, report = resize_single_hole(current_mesh, hole, target_diameter) + reports.append(report) + + # Build new meshes tuple with the modified mesh + new_meshes = tuple( + current_mesh if i == mesh_index else m + for i, m in enumerate(threemf_data.meshes) + ) + + return replace(threemf_data, meshes=new_meshes), reports + + +def resize_single_hole( + mesh_data: MeshData, + hole: DetectedHole, + target_diameter: float, +) -> tuple[MeshData, dict]: + """Resize a single hole and return ``(new_mesh, change_report)``. + + The change_report contains:: + + { + "hole_id": int, + "old_diameter": float, + "new_diameter": float, + "vertices_moved": int, + } + + Wall-vertex detection strategy (proven approach) + ------------------------------------------------- + * Compute distance from hole axis for every vertex. + * Select vertices whose radial distance is within ``_WALL_RADIUS_TOLERANCE`` + of the current hole radius **and** whose axial coordinate falls within + the hole extent (with tolerance). + * Scale those vertices radially to ``target_diameter / 2``. + """ + + if mesh_data is None: + raise ValueError("mesh_data must not be None") + if target_diameter <= 0: + raise ValueError(f"target_diameter must be positive, got {target_diameter}") + + old_radius = hole.diameter / 2.0 + new_radius = target_diameter / 2.0 + + perp = _perpendicular_axes(hole.axis) + wall_mask = _find_wall_vertices( + mesh_data.vertices, + hole.center, + old_radius, + hole.axis, + perp, + hole.axis_min, + hole.axis_max, + ) + + vertices_moved = int(np.count_nonzero(wall_mask)) + + if vertices_moved == 0: + return mesh_data, _make_report(hole, target_diameter, 0) + + new_vertices = _scale_wall_radially( + mesh_data.vertices, wall_mask, hole.center, perp, new_radius, + ) + + new_mesh = replace(mesh_data, vertices=new_vertices) + return new_mesh, _make_report(hole, target_diameter, vertices_moved) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _validate_resize_inputs( + threemf_data: ThreeMFData, + hole_ids: list[int], + target_diameter: float, + mesh_index: int, +) -> None: + if threemf_data is None: + raise ValueError("threemf_data must not be None") + if not threemf_data.meshes: + raise ValueError("threemf_data contains no meshes") + if mesh_index < 0 or mesh_index >= len(threemf_data.meshes): + raise ValueError( + f"mesh_index {mesh_index} out of range " + f"(0..{len(threemf_data.meshes) - 1})" + ) + if target_diameter <= 0: + raise ValueError(f"target_diameter must be positive, got {target_diameter}") + if not hole_ids: + raise ValueError("hole_ids must not be empty") + + +def _perpendicular_axes(axis: int) -> tuple[int, int]: + """Return the two axes perpendicular to *axis*.""" + return {0: (1, 2), 1: (0, 2), 2: (0, 1)}[axis] + + +def _find_wall_vertices( + vertices: np.ndarray, + center: tuple[float, float], + radius: float, + axis: int, + perp: tuple[int, int], + axis_min: float, + axis_max: float, + tolerance: float = _WALL_RADIUS_TOLERANCE, +) -> np.ndarray: + """Return a boolean mask of vertices on the cylindrical wall.""" + + ax0, ax1 = perp + in_range = (vertices[:, axis] >= axis_min - tolerance) & ( + vertices[:, axis] <= axis_max + tolerance + ) + dx = vertices[:, ax0] - center[0] + dy = vertices[:, ax1] - center[1] + dist = np.sqrt(dx * dx + dy * dy) + on_wall = np.abs(dist - radius) < tolerance + return in_range & on_wall + + +def _scale_wall_radially( + vertices: np.ndarray, + mask: np.ndarray, + center: tuple[float, float], + perp: tuple[int, int], + new_radius: float, +) -> np.ndarray: + """Scale wall vertices to *new_radius*. Returns a **new** vertex array.""" + + new_verts = vertices.copy() + ax0, ax1 = perp + + dx = new_verts[mask, ax0] - center[0] + dy = new_verts[mask, ax1] - center[1] + dist = np.sqrt(dx * dx + dy * dy) + + # Avoid division by zero for vertices exactly at the centre + safe_dist = np.where(dist > 1e-12, dist, 1.0) + scale = new_radius / safe_dist + + new_verts[mask, ax0] = center[0] + dx * scale + new_verts[mask, ax1] = center[1] + dy * scale + + return new_verts + + +def _make_report(hole: DetectedHole, target_diameter: float, vertices_moved: int) -> dict: + return { + "hole_id": hole.hole_id, + "old_diameter": hole.diameter, + "new_diameter": round(target_diameter, 4), + "vertices_moved": vertices_moved, + } diff --git a/3MF/agent-harness/cli_anything/threemf/core/parser.py b/3MF/agent-harness/cli_anything/threemf/core/parser.py new file mode 100644 index 0000000..285359a --- /dev/null +++ b/3MF/agent-harness/cli_anything/threemf/core/parser.py @@ -0,0 +1,467 @@ +"""3MF file parsing and writing -- handles ZIP structure and XML mesh data. + +A 3MF file is a standard ZIP archive that contains one or more XML files +describing 3D mesh objects. The primary model is typically located at +``3D/3dmodel.model``. This module provides lossless round-trip support: +non-model entries (thumbnails, slicer profiles, Bambu metadata, etc.) are +preserved byte-for-byte. +""" + +from __future__ import annotations + +import io +import os +import zipfile +from dataclasses import dataclass +from xml.etree import ElementTree as ET + +import numpy as np + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +NS_CORE = "http://schemas.microsoft.com/3dmanufacturing/core/2015/02" +NS_BAMBU = "http://schemas.bambulab.com/package/2021" + +# Common namespace prefixes found in 3MF files -- we register them so that +# ElementTree preserves them when serialising instead of inventing ``ns0`` +# etc. +_KNOWN_NS: dict[str, str] = { + "": NS_CORE, + "b": NS_BAMBU, +} + +# Ensure ElementTree will use the prefixes above when writing. +for _prefix, _uri in _KNOWN_NS.items(): + ET.register_namespace(_prefix, _uri) + + +def _tag(local: str, ns: str = NS_CORE) -> str: + """Return a fully-qualified ``{namespace}local`` tag string.""" + return f"{{{ns}}}{local}" + + +# --------------------------------------------------------------------------- +# Data classes (frozen / immutable) +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class MeshData: + """Immutable container for a single mesh object. + + Attributes: + object_id: The ``id`` attribute of the ```` element. + name: The ``name`` attribute (may be empty). + vertices: (N, 3) float64 array of vertex coordinates. + triangles: (M, 3) int32 array of triangle vertex-indices. + triangle_attributes: Per-triangle XML attributes other than + ``v1``, ``v2``, and ``v3``. This preserves 3MF + material/property attributes such as ``pid``, ``p1``, + ``p2``, and ``p3`` during mesh edits. + """ + + object_id: str + name: str + vertices: np.ndarray # (N, 3) float64 + triangles: np.ndarray # (M, 3) int32 + triangle_attributes: tuple[dict[str, str], ...] = () + + def __post_init__(self) -> None: + # Validate shapes -- run only once at construction time. + if self.vertices.ndim != 2 or self.vertices.shape[1] != 3: + raise ValueError( + f"vertices must be (N, 3); got {self.vertices.shape}" + ) + if self.triangles.ndim != 2 or self.triangles.shape[1] != 3: + raise ValueError( + f"triangles must be (M, 3); got {self.triangles.shape}" + ) + if self.triangle_attributes and ( + len(self.triangle_attributes) != self.triangles.shape[0] + ): + raise ValueError( + "triangle_attributes length must match triangle count; " + f"got {len(self.triangle_attributes)} attributes for " + f"{self.triangles.shape[0]} triangles" + ) + + +@dataclass(frozen=True) +class ThreeMFData: + """Immutable container for a complete 3MF file. + + Attributes: + meshes: Tuple of :class:`MeshData` objects. + unit: Model unit string (``millimeter``, ``inch``, etc.). + model_path: ZIP-internal path of the model XML file. + metadata: ``{name: value}`` dict of ```` elements. + raw_entries: ``{zip_path: bytes}`` of every non-model ZIP member, + kept for lossless round-trip. + source_path: Filesystem path the data was loaded from. + """ + + meshes: tuple[MeshData, ...] + unit: str + model_path: str + metadata: dict[str, str] + raw_entries: dict[str, bytes] + source_path: str + + +# --------------------------------------------------------------------------- +# Parsing +# --------------------------------------------------------------------------- + +def _find_model_path(zf: zipfile.ZipFile) -> str: + """Locate the primary model XML inside the ZIP archive. + + Checks in order: + 1. ``3D/3dmodel.model`` (standard location) + 2. First ``.model`` file found anywhere in the archive + """ + standard = "3D/3dmodel.model" + names = zf.namelist() + if standard in names: + return standard + for name in names: + if name.lower().endswith(".model"): + return name + raise FileNotFoundError( + "No .model file found inside the 3MF archive" + ) + + +def _parse_mesh_element( + obj_elem: ET.Element, + ns: str, +) -> MeshData | None: + """Extract mesh arrays from an ```` element. + + Returns ``None`` when the element has no ```` child (e.g. + component-only objects). + """ + mesh_elem = obj_elem.find(_tag("mesh", ns)) + if mesh_elem is None: + return None + + object_id = obj_elem.get("id", "") + name = obj_elem.get("name", "") + + # -- vertices ---------------------------------------------------------- + verts_elem = mesh_elem.find(_tag("vertices", ns)) + if verts_elem is None: + return None + + vert_list: list[tuple[float, float, float]] = [] + for v in verts_elem.iterfind(_tag("vertex", ns)): + x = float(v.get("x", "0")) + y = float(v.get("y", "0")) + z = float(v.get("z", "0")) + vert_list.append((x, y, z)) + + if not vert_list: + return None + + vertices = np.array(vert_list, dtype=np.float64) + + # -- triangles --------------------------------------------------------- + tris_elem = mesh_elem.find(_tag("triangles", ns)) + if tris_elem is None: + return None + + tri_list: list[tuple[int, int, int]] = [] + triangle_attributes: list[dict[str, str]] = [] + for t in tris_elem.iterfind(_tag("triangle", ns)): + v1 = int(t.get("v1", "0")) + v2 = int(t.get("v2", "0")) + v3 = int(t.get("v3", "0")) + tri_list.append((v1, v2, v3)) + triangle_attributes.append( + { + key: value + for key, value in t.attrib.items() + if key not in {"v1", "v2", "v3"} + } + ) + + if not tri_list: + return None + + triangles = np.array(tri_list, dtype=np.int32) + + return MeshData( + object_id=object_id, + name=name, + vertices=vertices, + triangles=triangles, + triangle_attributes=tuple(triangle_attributes), + ) + + +def _detect_namespace(root: ET.Element) -> str: + """Extract the primary namespace from the root ```` element. + + Falls back to :data:`NS_CORE` when no namespace is present. + """ + tag = root.tag + if tag.startswith("{"): + return tag[1 : tag.index("}")] + return NS_CORE + + +def parse_3mf(path: str) -> ThreeMFData: + """Parse a 3MF file into an immutable :class:`ThreeMFData` structure. + + Steps + ----- + 1. Open the file as a :class:`zipfile.ZipFile`. + 2. Locate the primary model XML (usually ``3D/3dmodel.model``). + 3. Parse the XML; detect the 3MF core namespace. + 4. Extract ```` elements with ```` children. + 5. Collect ```` key/value pairs. + 6. Store every *non-model* ZIP member as raw bytes so that a subsequent + :func:`write_3mf` call can preserve slicer settings, thumbnails, + and other auxiliary data. + + Parameters + ---------- + path: + Filesystem path to a ``.3mf`` file. + + Returns + ------- + ThreeMFData + Frozen dataclass with all parsed data. + + Raises + ------ + FileNotFoundError + If *path* does not exist or the archive contains no model XML. + zipfile.BadZipFile + If the file is not a valid ZIP archive. + """ + path = os.path.abspath(path) + + if not os.path.isfile(path): + raise FileNotFoundError(f"File not found: {path}") + + with zipfile.ZipFile(path, "r") as zf: + model_path = _find_model_path(zf) + + # -- raw entries (everything except the model XML) ----------------- + raw_entries: dict[str, bytes] = {} + for entry in zf.namelist(): + if entry != model_path: + raw_entries[entry] = zf.read(entry) + + # -- parse model XML ----------------------------------------------- + model_bytes = zf.read(model_path) + + root = ET.fromstring(model_bytes) + ns = _detect_namespace(root) + + # Unit + unit = root.get("unit", "millimeter") + + # Metadata + metadata: dict[str, str] = {} + for meta in root.iterfind(_tag("metadata", ns)): + meta_name = meta.get("name", "") + meta_value = meta.text or "" + if meta_name: + metadata[meta_name] = meta_value + + # Meshes + meshes: list[MeshData] = [] + resources = root.find(_tag("resources", ns)) + if resources is not None: + for obj in resources.iterfind(_tag("object", ns)): + mesh_data = _parse_mesh_element(obj, ns) + if mesh_data is not None: + meshes.append(mesh_data) + + return ThreeMFData( + meshes=tuple(meshes), + unit=unit, + model_path=model_path, + metadata=dict(metadata), + raw_entries=dict(raw_entries), + source_path=path, + ) + + +# --------------------------------------------------------------------------- +# Writing +# --------------------------------------------------------------------------- + +def _rebuild_model_xml(data: ThreeMFData) -> bytes: + """Rebuild the model XML from *data*, preserving non-mesh elements. + + The strategy is: + 1. Re-parse the original XML from ``raw_entries`` or regenerate from + scratch. + 2. For each ```` whose id matches one of our :class:`MeshData` + objects, replace its ```` sub-tree with the (possibly modified) + vertex/triangle arrays. + 3. Leave every other element untouched. + + When no original XML is available (e.g. the caller built + :class:`ThreeMFData` programmatically), a minimal valid model is + generated. + """ + + # -- attempt to load the original XML for preservation ----------------- + original_bytes: bytes | None = None + + # If the caller kept raw bytes for the model path (unlikely but + # possible), use them; otherwise we need the original file. + if data.source_path and os.path.isfile(data.source_path): + try: + with zipfile.ZipFile(data.source_path, "r") as zf: + original_bytes = zf.read(data.model_path) + except Exception: + original_bytes = None + + mesh_lookup: dict[str, MeshData] = {m.object_id: m for m in data.meshes} + + if original_bytes is not None: + root = ET.fromstring(original_bytes) + ns = _detect_namespace(root) + + resources = root.find(_tag("resources", ns)) + if resources is not None: + for obj in resources.iterfind(_tag("object", ns)): + oid = obj.get("id", "") + if oid not in mesh_lookup: + continue + md = mesh_lookup[oid] + + # Remove old mesh element + old_mesh = obj.find(_tag("mesh", ns)) + if old_mesh is not None: + obj.remove(old_mesh) + + # Build new mesh element + new_mesh = _build_mesh_element(md, ns) + obj.append(new_mesh) + else: + # Build from scratch + root = _build_model_from_scratch(data) + + # Serialise + tree = ET.ElementTree(root) + buf = io.BytesIO() + tree.write( + buf, + xml_declaration=True, + encoding="UTF-8", + ) + return buf.getvalue() + + +def _build_mesh_element(md: MeshData, ns: str) -> ET.Element: + """Create a ```` XML element from vertex/triangle arrays.""" + mesh_el = ET.Element(_tag("mesh", ns)) + + # Vertices + verts_el = ET.SubElement(mesh_el, _tag("vertices", ns)) + for row in md.vertices: + ET.SubElement( + verts_el, + _tag("vertex", ns), + x=f"{row[0]:.6g}", + y=f"{row[1]:.6g}", + z=f"{row[2]:.6g}", + ) + + # Triangles + tris_el = ET.SubElement(mesh_el, _tag("triangles", ns)) + for index, row in enumerate(md.triangles): + attrs = ( + dict(md.triangle_attributes[index]) + if md.triangle_attributes + else {} + ) + attrs.update( + { + "v1": str(row[0]), + "v2": str(row[1]), + "v3": str(row[2]), + } + ) + ET.SubElement( + tris_el, + _tag("triangle", ns), + attrs, + ) + + return mesh_el + + +def _build_model_from_scratch(data: ThreeMFData) -> ET.Element: + """Generate a minimal ```` tree when no original XML exists.""" + root = ET.Element( + _tag("model", NS_CORE), + unit=data.unit, + ) + + # Metadata + for name, value in data.metadata.items(): + meta = ET.SubElement(root, _tag("metadata", NS_CORE), name=name) + meta.text = value + + # Resources + resources = ET.SubElement(root, _tag("resources", NS_CORE)) + build = ET.SubElement(root, _tag("build", NS_CORE)) + + for md in data.meshes: + obj = ET.SubElement( + resources, + _tag("object", NS_CORE), + id=md.object_id, + type="model", + ) + if md.name: + obj.set("name", md.name) + + mesh_el = _build_mesh_element(md, NS_CORE) + obj.append(mesh_el) + + ET.SubElement(build, _tag("item", NS_CORE), objectid=md.object_id) + + return root + + +def write_3mf(data: ThreeMFData, output_path: str) -> None: + """Write a :class:`ThreeMFData` structure to a 3MF (ZIP) file. + + Steps + ----- + 1. Rebuild the model XML from the in-memory vertex/triangle data while + preserving every non-mesh element (metadata, build items, extension + namespaces, slicer settings embedded as attributes, etc.). + 2. Copy all ``raw_entries`` (thumbnails, Bambu profiles, OPC + relationships, ...) into the new ZIP archive. + 3. Write the rebuilt model XML at its original ``model_path``. + + Parameters + ---------- + data: + The model data to write. + output_path: + Destination filesystem path. Parent directories are created if + they do not exist. + """ + output_path = os.path.abspath(output_path) + os.makedirs(os.path.dirname(output_path), exist_ok=True) + + model_xml_bytes = _rebuild_model_xml(data) + + with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf: + # Write raw (non-model) entries first + for entry_path, entry_bytes in data.raw_entries.items(): + zf.writestr(entry_path, entry_bytes) + + # Write the model XML + zf.writestr(data.model_path, model_xml_bytes) diff --git a/3MF/agent-harness/cli_anything/threemf/core/repair.py b/3MF/agent-harness/cli_anything/threemf/core/repair.py new file mode 100644 index 0000000..9eede18 --- /dev/null +++ b/3MF/agent-harness/cli_anything/threemf/core/repair.py @@ -0,0 +1,257 @@ +"""Mesh repair -- fix degenerate faces, duplicate vertices, and normals.""" + +from __future__ import annotations + +from dataclasses import replace + +import numpy as np + +from cli_anything.threemf.core.parser import MeshData + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def repair_mesh(mesh_data: MeshData) -> tuple[MeshData, dict]: + """Run the full repair pipeline on *mesh_data*. + + Returns ``(repaired_mesh, report)`` where *report* summarises the + number of fixes applied at each step. + + Pipeline + -------- + 1. Merge duplicate vertices (rounded coordinate matching). + 2. Remap triangle indices to canonical vertices. + 3. Remove degenerate triangles (two or more identical indices). + 4. Remove unreferenced vertices and compact indices. + 5. Return the repaired mesh and a report dict. + """ + + _validate_mesh(mesh_data) + + vertices = mesh_data.vertices + triangles = mesh_data.triangles + + # Handle empty mesh early + if vertices.shape[0] == 0 or triangles.shape[0] == 0: + return mesh_data, _empty_report() + + # Step 1+2 -- merge duplicates and remap triangles + vertices, triangles, merge_count = merge_duplicate_vertices( + vertices, triangles, + ) + + # Step 3 -- remove degenerate faces + valid_faces = _nondegenerate_face_mask(triangles) + degen_count = int(triangles.shape[0] - np.count_nonzero(valid_faces)) + triangles = triangles[valid_faces] + triangle_attributes = _filter_triangle_attributes( + mesh_data.triangle_attributes, + valid_faces, + ) + + # Step 4 -- remove unreferenced vertices + vertices, triangles, unref_count = remove_unreferenced_vertices( + vertices, triangles, + ) + + report = { + "vertices_merged": merge_count, + "degenerate_faces_removed": degen_count, + "unreferenced_vertices_removed": unref_count, + "final_vertex_count": int(vertices.shape[0]), + "final_triangle_count": int(triangles.shape[0]), + } + + repaired = replace( + mesh_data, + vertices=vertices, + triangles=triangles, + triangle_attributes=triangle_attributes, + ) + return repaired, report + + +def merge_duplicate_vertices( + vertices: np.ndarray, + triangles: np.ndarray, + decimals: int = 6, +) -> tuple[np.ndarray, np.ndarray, int]: + """Merge vertices that share the same coordinates (after rounding). + + Returns ``(unique_vertices, remapped_triangles, merge_count)`` + where *merge_count* is the number of vertices removed. + """ + + if vertices.shape[0] == 0: + return vertices, triangles, 0 + + rounded = np.round(vertices, decimals=decimals) + + # Build a mapping from rounded coordinates to canonical index + # Using a structured view for fast unique-row detection + dtype = np.dtype([("x", rounded.dtype), ("y", rounded.dtype), ("z", rounded.dtype)]) + structured = np.ascontiguousarray(rounded).view(dtype).reshape(-1) + + _, canonical_idx, inverse = np.unique( + structured, return_index=True, return_inverse=True, + ) + + unique_vertices = vertices[np.sort(canonical_idx)] + + # Build old-index -> new-index mapping (preserving sorted order of canonical) + sorted_canonical = np.sort(canonical_idx) + new_idx_map = np.full(vertices.shape[0], -1, dtype=np.intp) + for new_i, old_i in enumerate(sorted_canonical): + new_idx_map[old_i] = new_i + + # Remap each original index through inverse -> canonical -> new_sorted + remap = new_idx_map[canonical_idx[inverse]] + + remapped_triangles = remap[triangles] if triangles.shape[0] > 0 else triangles + + merge_count = int(vertices.shape[0] - unique_vertices.shape[0]) + return unique_vertices, remapped_triangles, merge_count + + +def remove_degenerate_faces( + triangles: np.ndarray, +) -> tuple[np.ndarray, int]: + """Remove triangles where two or more vertex indices are identical. + + Returns ``(filtered_triangles, removed_count)``. + """ + + if triangles.shape[0] == 0: + return triangles, 0 + + valid = _nondegenerate_face_mask(triangles) + filtered = triangles[valid] + removed = int(triangles.shape[0] - filtered.shape[0]) + return filtered, removed + + +def remove_unreferenced_vertices( + vertices: np.ndarray, + triangles: np.ndarray, +) -> tuple[np.ndarray, np.ndarray, int]: + """Remove vertices not referenced by any triangle and compact indices. + + Returns ``(filtered_vertices, remapped_triangles, removed_count)``. + """ + + if vertices.shape[0] == 0: + return vertices, triangles, 0 + + if triangles.shape[0] == 0: + # No triangles reference any vertex -- remove all + empty_verts = np.empty((0, 3), dtype=vertices.dtype) + empty_tris = np.empty((0, 3), dtype=triangles.dtype) + return empty_verts, empty_tris, int(vertices.shape[0]) + + referenced = np.unique(triangles.ravel()) + removed_count = int(vertices.shape[0] - referenced.shape[0]) + + if removed_count == 0: + return vertices, triangles, 0 + + filtered_vertices = vertices[referenced] + + # Build old-index -> new-index mapping + remap = np.full(vertices.shape[0], -1, dtype=np.intp) + remap[referenced] = np.arange(referenced.shape[0], dtype=np.intp) + + remapped_triangles = remap[triangles] + return filtered_vertices, remapped_triangles, removed_count + + +def fix_normals(mesh_data: MeshData) -> MeshData: + """Fix face winding for consistent outward-pointing normals using trimesh. + + Returns a **new** ``MeshData`` with corrected triangle winding order. + """ + + _validate_mesh(mesh_data) + + if mesh_data.vertices.shape[0] == 0 or mesh_data.triangles.shape[0] == 0: + return mesh_data + + try: + import trimesh + except ImportError as exc: + raise ImportError( + "trimesh is required for normal repair. " + "Install with: pip install trimesh" + ) from exc + + tm = trimesh.Trimesh( + vertices=mesh_data.vertices.copy(), + faces=mesh_data.triangles.copy(), + process=False, + ) + trimesh.repair.fix_normals(tm) + + fixed_triangles = np.array(tm.faces) + triangle_attributes = ( + mesh_data.triangle_attributes + if len(mesh_data.triangle_attributes) == fixed_triangles.shape[0] + else () + ) + + return replace( + mesh_data, + vertices=np.array(tm.vertices), + triangles=fixed_triangles, + triangle_attributes=triangle_attributes, + ) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _validate_mesh(mesh_data: MeshData) -> None: + """Raise ``ValueError`` for clearly invalid mesh data.""" + if mesh_data is None: + raise ValueError("mesh_data must not be None") + if not isinstance(mesh_data.vertices, np.ndarray): + raise ValueError("mesh_data.vertices must be a numpy ndarray") + if not isinstance(mesh_data.triangles, np.ndarray): + raise ValueError("mesh_data.triangles must be a numpy ndarray") + if mesh_data.vertices.ndim != 2 or mesh_data.vertices.shape[1] != 3: + raise ValueError("mesh_data.vertices must have shape (N, 3)") + if mesh_data.triangles.ndim != 2 or mesh_data.triangles.shape[1] != 3: + if mesh_data.triangles.shape[0] != 0: + raise ValueError("mesh_data.triangles must have shape (M, 3)") + + +def _nondegenerate_face_mask(triangles: np.ndarray) -> np.ndarray: + if triangles.shape[0] == 0: + return np.zeros((0,), dtype=bool) + v0, v1, v2 = triangles[:, 0], triangles[:, 1], triangles[:, 2] + return (v0 != v1) & (v1 != v2) & (v0 != v2) + + +def _filter_triangle_attributes( + triangle_attributes: tuple[dict[str, str], ...], + mask: np.ndarray, +) -> tuple[dict[str, str], ...]: + if not triangle_attributes: + return () + return tuple( + dict(attrs) + for attrs, keep in zip(triangle_attributes, mask) + if bool(keep) + ) + + +def _empty_report() -> dict: + return { + "vertices_merged": 0, + "degenerate_faces_removed": 0, + "unreferenced_vertices_removed": 0, + "final_vertex_count": 0, + "final_triangle_count": 0, + } diff --git a/3MF/agent-harness/cli_anything/threemf/skills/SKILL.md b/3MF/agent-harness/cli_anything/threemf/skills/SKILL.md new file mode 100644 index 0000000..096fea1 --- /dev/null +++ b/3MF/agent-harness/cli_anything/threemf/skills/SKILL.md @@ -0,0 +1,49 @@ +--- +name: "cli-anything-3mf" +description: "3MF mesh geometry editor — detect and resize cylindrical holes, repair meshes, compare 3D printing files. Works with BambuStudio and PrusaSlicer 3MF files." +--- + +# cli-anything-3mf + +3MF mesh geometry editor for 3D printing files. + +## Installation + +```bash +pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=3MF/agent-harness +``` + +## Commands + +```bash +# Show mesh info (vertices, faces, bounding box, watertight status, volume) +cli-anything-3mf info + +# Detect cylindrical holes (center, diameter, confidence) +cli-anything-3mf inspect + +# Resize holes to target diameter +cli-anything-3mf resize --hole 0 --hole 1 --diameter 4.2 -o output.3mf + +# Fix mesh issues (degenerate faces, duplicate vertices, normals) +cli-anything-3mf repair -o repaired.3mf + +# Compare two 3MF files +cli-anything-3mf compare +``` + +## JSON Output + +All commands support `--json` for machine-readable output: + +```bash +cli-anything-3mf --json inspect model.3mf +``` + +## Key Features + +- Detects cylindrical holes via multi-plane cross-section analysis +- Resizes holes by radial vertex scaling (preserves mesh topology) +- Preserves slicer metadata (BambuStudio, PrusaSlicer) during file repack +- Repairs degenerate faces and duplicate vertices after modification +- Works with any 3MF file conforming to the 3MF Core Specification diff --git a/3MF/agent-harness/cli_anything/threemf/tests/__init__.py b/3MF/agent-harness/cli_anything/threemf/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/3MF/agent-harness/cli_anything/threemf/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/3MF/agent-harness/cli_anything/threemf/tests/test_core.py b/3MF/agent-harness/cli_anything/threemf/tests/test_core.py new file mode 100644 index 0000000..edad5fb --- /dev/null +++ b/3MF/agent-harness/cli_anything/threemf/tests/test_core.py @@ -0,0 +1,1260 @@ +"""Comprehensive unit tests for the 3MF core modules. + +Covers: +- cli_anything.threemf.core.parser (MeshData, ThreeMFData) +- cli_anything.threemf.utils.threemf_backend (geometry utilities) +- cli_anything.threemf.core.inspector (InspectParams, DetectedHole, + inspect_mesh, compare_meshes) +- cli_anything.threemf.core.repair (repair_mesh, merge_duplicate_vertices, + remove_degenerate_faces, + remove_unreferenced_vertices, fix_normals) +- cli_anything.threemf.core.modifier (resize_holes, resize_single_hole) + +Tests use synthetic numpy data and generated in-memory 3MF fixtures -- no +external 3MF files are required. +""" + +from __future__ import annotations + +import zipfile +from xml.etree import ElementTree as ET + +import numpy as np +import pytest + +from cli_anything.threemf.core.parser import MeshData, ThreeMFData +from cli_anything.threemf.utils import threemf_backend as backend +from cli_anything.threemf.core import inspector, repair, modifier +from cli_anything.threemf.core import parser as parser_mod +from cli_anything.threemf.core.inspector import DetectedHole, InspectParams +from cli_anything.threemf.core.modifier import resize_holes, resize_single_hole + + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + +def _make_cube_mesh(size: float = 10.0) -> MeshData: + """Create a simple closed-cube MeshData for testing.""" + s = size / 2 + vertices = np.array( + [ + [-s, -s, -s], [s, -s, -s], [s, s, -s], [-s, s, -s], + [-s, -s, s], [s, -s, s], [s, s, s], [-s, s, s], + ], + dtype=np.float64, + ) + triangles = np.array( + [ + [0, 2, 1], [0, 3, 2], + [4, 5, 6], [4, 6, 7], + [0, 1, 5], [0, 5, 4], + [2, 3, 7], [2, 7, 6], + [0, 4, 7], [0, 7, 3], + [1, 2, 6], [1, 6, 5], + ], + dtype=np.int32, + ) + return MeshData(object_id="1", name="cube", vertices=vertices, triangles=triangles) + + +def _make_empty_mesh() -> MeshData: + return MeshData( + object_id="0", + name="empty", + vertices=np.empty((0, 3), dtype=np.float64), + triangles=np.empty((0, 3), dtype=np.int32), + ) + + +def _make_threemf_data(mesh: MeshData | None = None) -> ThreeMFData: + """Wrap a single mesh (or the default cube) in a ThreeMFData.""" + if mesh is None: + mesh = _make_cube_mesh() + return ThreeMFData( + meshes=(mesh,), + unit="millimeter", + model_path="3D/3dmodel.model", + metadata={"Author": "test"}, + raw_entries={}, + source_path="", + ) + + +def _write_triangle_attr_3mf(path, triangles_xml: str | None = None) -> None: + if triangles_xml is None: + triangles_xml = """ + + + """ + model_xml = f""" + + + + + + + + + + + +{triangles_xml} + + + + + + + + + + + + + +""" + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("[Content_Types].xml", b"fixture") + zf.writestr("3D/3dmodel.model", model_xml.encode("utf-8")) + + +def _model_root(path) -> ET.Element: + with zipfile.ZipFile(path, "r") as zf: + return ET.fromstring(zf.read("3D/3dmodel.model")) + + +def _triangle_attrs(path) -> list[dict[str, str]]: + root = _model_root(path) + triangles = root.findall(f".//{{{parser_mod.NS_CORE}}}triangle") + return [dict(triangle.attrib) for triangle in triangles] + + +def _make_circle_points( + cx: float = 0.0, cy: float = 0.0, r: float = 5.0, n: int = 60 +) -> np.ndarray: + """Return (n, 2) points uniformly distributed on a circle.""" + theta = np.linspace(0, 2 * np.pi, n, endpoint=False) + return np.column_stack([cx + r * np.cos(theta), cy + r * np.sin(theta)]) + + +def _make_mesh_with_duplicates() -> MeshData: + """Cube with two duplicate vertices appended.""" + cube = _make_cube_mesh(4.0) + extra = cube.vertices[:2].copy() + vertices = np.vstack([cube.vertices, extra]) + triangles = np.vstack([ + cube.triangles, + np.array([[8, 9, 2]], dtype=np.int32), + ]) + return MeshData(object_id="dup", name="dup", vertices=vertices, triangles=triangles) + + +def _make_mesh_with_degenerate() -> MeshData: + """Cube with one degenerate triangle (two identical indices) appended.""" + cube = _make_cube_mesh(4.0) + degen = np.array([[0, 0, 1]], dtype=np.int32) + triangles = np.vstack([cube.triangles, degen]) + return MeshData(object_id="deg", name="deg", vertices=cube.vertices, triangles=triangles) + + +def _make_fake_hole( + center: tuple[float, float] = (0.0, 0.0), + diameter: float = 3.0, + hole_id: int = 0, + axis: int = 0, +) -> DetectedHole: + return DetectedHole( + hole_id=hole_id, + center=center, + diameter=diameter, + axis_min=-5.0, + axis_max=5.0, + axis=axis, + confidence=0.9, + vertex_count=10, + ) + + +def _make_washer_mesh( + r_inner: float = 4.0, r_outer: float = 20.0, height: float = 10.0 +) -> MeshData: + """A washer (hollow cylinder) whose axis runs along Z. + + Its cross-section perpendicular to Z is an annulus -- two *concentric* + circles (inner hole ``r_inner``, outer wall ``r_outer``). This is the + minimal fixture that reproduces the concentric-circle bugs: a real hole + bored through a body always yields both the hole contour and the body + contour in the same section. + """ + import trimesh + + tm = trimesh.creation.annulus(r_min=r_inner, r_max=r_outer, height=height) + return MeshData( + object_id="1", + name="washer", + vertices=np.asarray(tm.vertices, dtype=np.float64), + triangles=np.asarray(tm.faces, dtype=np.int32), + ) + + +def _make_boss_on_plate_mesh( + boss_radius: float = 5.0, boss_height: float = 20.0, + plate_size: float = 50.0, plate_thickness: float = 4.0, +) -> MeshData: + """A solid cylindrical boss standing on a wider flat plate (boss axis = Z). + + Built by concatenation (no boolean backend needed). Cross-sections through + the boss are circular so detection finds it, but the surrounding plate + material sits only at the base end -- so it must be classified as an exterior + contour, not an interior hole. + """ + import trimesh + + plate = trimesh.creation.box(extents=(plate_size, plate_size, plate_thickness)) + boss = trimesh.creation.cylinder(radius=boss_radius, height=boss_height, sections=48) + boss.apply_translation([0, 0, plate_thickness / 2 + boss_height / 2]) + tm = trimesh.util.concatenate([plate, boss]) + return MeshData( + object_id="1", + name="boss", + vertices=np.asarray(tm.vertices, dtype=np.float64), + triangles=np.asarray(tm.faces, dtype=np.int32), + ) + + +# =========================================================================== +# TestParser -- MeshData and ThreeMFData +# =========================================================================== + + +class TestParser: + """Tests for cli_anything.threemf.core.parser.""" + + # --- MeshData construction ------------------------------------------------- + + def test_mesh_data_basic_construction(self) -> None: + """MeshData is created with correct attributes.""" + mesh = _make_cube_mesh() + assert mesh.object_id == "1" + assert mesh.name == "cube" + assert mesh.vertices.shape == (8, 3) + assert mesh.triangles.shape == (12, 3) + + def test_mesh_data_vertex_dtype(self) -> None: + """Vertices array dtype is float64.""" + assert _make_cube_mesh().vertices.dtype == np.float64 + + def test_mesh_data_triangle_dtype(self) -> None: + """Triangles array dtype is int32.""" + assert _make_cube_mesh().triangles.dtype == np.int32 + + def test_mesh_data_triangle_attributes_length_must_match_faces(self) -> None: + """Per-triangle XML metadata must stay aligned with face rows.""" + v = np.zeros((3, 3), dtype=np.float64) + t = np.array([[0, 1, 2]], dtype=np.int32) + with pytest.raises(ValueError, match="triangle_attributes"): + MeshData( + object_id="1", + name="x", + vertices=v, + triangles=t, + triangle_attributes=({"pid": "1"}, {"pid": "2"}), + ) + + def test_mesh_data_is_frozen_on_object_id(self) -> None: + """MeshData raises FrozenInstanceError when object_id is reassigned.""" + mesh = _make_cube_mesh() + with pytest.raises(Exception): + mesh.object_id = "new_id" # type: ignore[misc] + + def test_mesh_data_is_frozen_on_vertices(self) -> None: + """MeshData raises FrozenInstanceError when vertices is reassigned.""" + mesh = _make_cube_mesh() + with pytest.raises(Exception): + mesh.vertices = np.zeros((8, 3)) # type: ignore[misc] + + def test_mesh_data_bad_vertex_shape_1d(self) -> None: + """1-D vertex array raises ValueError.""" + flat = np.array([0.0, 1.0, 2.0]) + t = np.array([[0, 1, 2]], dtype=np.int32) + with pytest.raises(ValueError, match="vertices"): + MeshData(object_id="1", name="x", vertices=flat, triangles=t) # type: ignore[arg-type] + + def test_mesh_data_bad_vertex_shape_wrong_columns(self) -> None: + """Vertex array with 2 columns raises ValueError.""" + v = np.zeros((4, 2), dtype=np.float64) + t = np.array([[0, 1, 2]], dtype=np.int32) + with pytest.raises(ValueError, match="vertices"): + MeshData(object_id="1", name="x", vertices=v, triangles=t) + + def test_mesh_data_bad_triangle_shape_wrong_columns(self) -> None: + """Triangle array with 4 columns raises ValueError.""" + v = np.zeros((4, 3), dtype=np.float64) + t = np.array([[0, 1, 2, 0]], dtype=np.int32) + with pytest.raises(ValueError, match="triangles"): + MeshData(object_id="1", name="x", vertices=v, triangles=t) + + def test_mesh_data_empty_name_is_allowed(self) -> None: + """Empty name string is valid.""" + v = np.zeros((3, 3), dtype=np.float64) + t = np.array([[0, 1, 2]], dtype=np.int32) + mesh = MeshData(object_id="1", name="", vertices=v, triangles=t) + assert mesh.name == "" + + def test_mesh_data_single_triangle(self) -> None: + """A minimal mesh with one triangle can be constructed.""" + v = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], dtype=np.float64) + t = np.array([[0, 1, 2]], dtype=np.int32) + mesh = MeshData(object_id="2", name="tri", vertices=v, triangles=t) + assert mesh.triangles.shape == (1, 3) + + def test_mesh_data_empty_construction(self) -> None: + """Empty mesh (0 vertices, 0 triangles) can be constructed.""" + m = _make_empty_mesh() + assert m.vertices.shape == (0, 3) + assert m.triangles.shape == (0, 3) + + # --- ThreeMFData construction ----------------------------------------------- + + def test_threemf_data_stores_all_fields(self) -> None: + """ThreeMFData stores all fields correctly.""" + data = _make_threemf_data() + assert data.unit == "millimeter" + assert data.model_path == "3D/3dmodel.model" + assert data.metadata["Author"] == "test" + assert len(data.meshes) == 1 + + def test_threemf_data_is_frozen(self) -> None: + """ThreeMFData raises FrozenInstanceError on attribute assignment.""" + data = _make_threemf_data() + with pytest.raises(Exception): + data.unit = "inch" # type: ignore[misc] + + def test_threemf_data_multiple_meshes(self) -> None: + """ThreeMFData can hold multiple meshes.""" + m1 = _make_cube_mesh(size=5.0) + m2 = MeshData( + object_id="2", + name="small", + vertices=np.zeros((3, 3), dtype=np.float64), + triangles=np.array([[0, 1, 2]], dtype=np.int32), + ) + data = ThreeMFData( + meshes=(m1, m2), + unit="millimeter", + model_path="3D/3dmodel.model", + metadata={}, + raw_entries={}, + source_path="", + ) + assert len(data.meshes) == 2 + + def test_threemf_data_empty_meshes(self) -> None: + """ThreeMFData with no meshes is valid.""" + data = ThreeMFData( + meshes=(), + unit="millimeter", + model_path="3D/3dmodel.model", + metadata={}, + raw_entries={}, + source_path="", + ) + assert len(data.meshes) == 0 + + def test_threemf_data_raw_entries_preserved(self) -> None: + """raw_entries bytes are stored unchanged.""" + payload = b"\x00\x01\x02\x03" + data = ThreeMFData( + meshes=(), + unit="millimeter", + model_path="3D/3dmodel.model", + metadata={}, + raw_entries={"thumbnail.png": payload}, + source_path="", + ) + assert data.raw_entries["thumbnail.png"] == payload + + def test_parse_resize_write_preserves_triangle_attributes_and_component_transforms(self, tmp_path) -> None: + """A 3MF fixture keeps triangle material/vendor attrs after vertex edits and write.""" + source = tmp_path / "attrs.3mf" + output = tmp_path / "resized.3mf" + _write_triangle_attr_3mf(source) + + data = parser_mod.parse_3mf(str(source)) + mesh = data.meshes[0] + assert mesh.triangle_attributes[0] == { + "pid": "7", + "p1": "1", + "p2": "2", + "p3": "3", + "custom": "kept", + } + + hole = DetectedHole( + hole_id=0, + center=(0.0, 0.0), + diameter=2.0, + axis_min=-0.1, + axis_max=0.1, + axis=2, + confidence=1.0, + vertex_count=4, + ) + resized_mesh, report = resize_single_hole(mesh, hole, target_diameter=4.0) + assert report["vertices_moved"] == 4 + new_data = ThreeMFData( + meshes=(resized_mesh,), + unit=data.unit, + model_path=data.model_path, + metadata=data.metadata, + raw_entries=data.raw_entries, + source_path=data.source_path, + ) + + parser_mod.write_3mf(new_data, str(output)) + + attrs = _triangle_attrs(output) + assert attrs[0]["pid"] == "7" + assert attrs[0]["p1"] == "1" + assert attrs[0]["p2"] == "2" + assert attrs[0]["p3"] == "3" + assert attrs[0]["custom"] == "kept" + vendor_key = "{urn:vendor:test}tag" + assert attrs[1][vendor_key] == "alpha" + + root = _model_root(output) + component = root.find(f".//{{{parser_mod.NS_CORE}}}component") + build_item = root.find(f".//{{{parser_mod.NS_CORE}}}item") + assert component is not None + assert build_item is not None + assert component.get("transform") == "1 0 0 0 1 0 0 0 1 10 20 30" + assert build_item.get("transform") == "1 0 0 0 1 0 0 0 1 5 0 0" + + def test_repair_write_preserves_attributes_for_surviving_triangles(self, tmp_path) -> None: + """Repair drops metadata only for faces removed from the mesh.""" + source = tmp_path / "degenerate.3mf" + output = tmp_path / "repaired.3mf" + triangles_xml = """ + + + + """ + _write_triangle_attr_3mf(source, triangles_xml=triangles_xml) + + data = parser_mod.parse_3mf(str(source)) + repaired, report = repair.repair_mesh(data.meshes[0]) + assert report["degenerate_faces_removed"] == 1 + assert [attrs["pid"] for attrs in repaired.triangle_attributes] == ["10", "20"] + + parser_mod.write_3mf( + ThreeMFData( + meshes=(repaired,), + unit=data.unit, + model_path=data.model_path, + metadata=data.metadata, + raw_entries=data.raw_entries, + source_path=data.source_path, + ), + str(output), + ) + + attrs = _triangle_attrs(output) + assert [item["pid"] for item in attrs] == ["10", "20"] + assert attrs[1]["custom"] == "survives" + assert all(item.get("removed") is None for item in attrs) + + +# =========================================================================== +# TestBackend -- threemf_backend geometry utilities +# =========================================================================== + + +class TestBackend: + """Tests for cli_anything.threemf.utils.threemf_backend.""" + + # --- mesh_to_trimesh ------------------------------------------------------- + + def test_mesh_to_trimesh_returns_trimesh_instance(self) -> None: + """mesh_to_trimesh returns a trimesh.Trimesh instance.""" + import trimesh + tm = backend.mesh_to_trimesh(_make_cube_mesh()) + assert isinstance(tm, trimesh.Trimesh) + + def test_mesh_to_trimesh_vertex_count(self) -> None: + """Converted trimesh has the same vertex count as MeshData.""" + mesh = _make_cube_mesh() + assert backend.mesh_to_trimesh(mesh).vertices.shape[0] == mesh.vertices.shape[0] + + def test_mesh_to_trimesh_face_count(self) -> None: + """Converted trimesh has the same face count as MeshData.""" + mesh = _make_cube_mesh() + assert backend.mesh_to_trimesh(mesh).faces.shape[0] == mesh.triangles.shape[0] + + def test_mesh_to_trimesh_does_not_mutate_source(self) -> None: + """mesh_to_trimesh does not mutate the original MeshData vertices.""" + mesh = _make_cube_mesh() + orig = mesh.vertices.copy() + tm = backend.mesh_to_trimesh(mesh) + tm.vertices[0, 0] = 9999.0 + assert np.allclose(mesh.vertices, orig) + + # --- trimesh_to_mesh ------------------------------------------------------- + + def test_trimesh_to_mesh_roundtrip_vertex_count(self) -> None: + """trimesh -> MeshData preserves vertex count.""" + mesh = _make_cube_mesh() + m2 = backend.trimesh_to_mesh(backend.mesh_to_trimesh(mesh), "1", "cube") + assert m2.vertices.shape[0] == mesh.vertices.shape[0] + + def test_trimesh_to_mesh_roundtrip_face_count(self) -> None: + """trimesh -> MeshData preserves face count.""" + mesh = _make_cube_mesh() + m2 = backend.trimesh_to_mesh(backend.mesh_to_trimesh(mesh), "1", "cube") + assert m2.triangles.shape[0] == mesh.triangles.shape[0] + + def test_trimesh_to_mesh_sets_object_id_and_name(self) -> None: + """trimesh_to_mesh sets object_id and name correctly.""" + tm = backend.mesh_to_trimesh(_make_cube_mesh()) + m2 = backend.trimesh_to_mesh(tm, "42", "mymesh") + assert m2.object_id == "42" + assert m2.name == "mymesh" + + def test_trimesh_to_mesh_values_match(self) -> None: + """Roundtrip preserves vertex coordinates.""" + mesh = _make_cube_mesh() + tm = backend.mesh_to_trimesh(mesh) + m2 = backend.trimesh_to_mesh(tm, "1", "cube") + np.testing.assert_array_almost_equal(m2.vertices, mesh.vertices) + + # --- compute_mesh_stats ---------------------------------------------------- + + def test_stats_vertex_count(self) -> None: + assert compute_mesh_stats_cube()["vertex_count"] == 8 + + def test_stats_face_count(self) -> None: + assert compute_mesh_stats_cube()["face_count"] == 12 + + def test_stats_watertight_closed_cube(self) -> None: + assert compute_mesh_stats_cube()["watertight"] is True + + def test_stats_volume_cube_1000(self) -> None: + stats = compute_mesh_stats_cube() + assert stats["volume_mm3"] is not None + assert abs(stats["volume_mm3"] - 1000.0) < 1.0 + + def test_stats_bounding_box_has_required_keys(self) -> None: + bb = compute_mesh_stats_cube()["bounding_box"] + assert "min" in bb and "max" in bb and "size" in bb + + def test_stats_bounding_box_values_correct(self) -> None: + bb = compute_mesh_stats_cube()["bounding_box"] + assert np.allclose(bb["min"], [-5.0, -5.0, -5.0], atol=1e-6) + assert np.allclose(bb["max"], [5.0, 5.0, 5.0], atol=1e-6) + assert np.allclose(bb["size"], [10.0, 10.0, 10.0], atol=1e-6) + + def test_stats_surface_area_cube(self) -> None: + stats = compute_mesh_stats_cube() + assert abs(stats["surface_area_mm2"] - 600.0) < 5.0 + + def test_stats_empty_mesh_defaults(self) -> None: + """compute_mesh_stats handles an empty mesh gracefully.""" + stats = backend.compute_mesh_stats(_make_empty_mesh()) + assert stats["vertex_count"] == 0 + assert stats["face_count"] == 0 + assert stats["watertight"] is False + assert stats["volume_mm3"] is None + assert stats["surface_area_mm2"] == 0.0 + + # --- fit_circle_least_squares ---------------------------------------------- + + def test_fit_circle_perfect_center_and_radius(self) -> None: + """fit_circle_least_squares recovers exact parameters of a perfect circle.""" + pts = _make_circle_points(cx=3.0, cy=2.0, r=5.0, n=100) + cx, cy, r, err = backend.fit_circle_least_squares(pts) + assert abs(cx - 3.0) < 1e-6 + assert abs(cy - 2.0) < 1e-6 + assert abs(r - 5.0) < 1e-6 + assert err < 1e-6 + + def test_fit_circle_unit_circle(self) -> None: + """fit_circle_least_squares works for unit circle at origin.""" + pts = _make_circle_points(cx=0.0, cy=0.0, r=1.0, n=60) + cx, cy, r, _ = backend.fit_circle_least_squares(pts) + assert abs(cx) < 1e-6 + assert abs(cy) < 1e-6 + assert abs(r - 1.0) < 1e-6 + + def test_fit_circle_too_few_points(self) -> None: + """fit_circle_least_squares raises ValueError for fewer than 3 points.""" + with pytest.raises(ValueError, match="At least 3"): + backend.fit_circle_least_squares(np.array([[0.0, 0.0], [1.0, 0.0]])) + + def test_fit_circle_wrong_shape(self) -> None: + """fit_circle_least_squares raises ValueError for (N,3) input.""" + pts = np.zeros((5, 3), dtype=np.float64) + with pytest.raises(ValueError, match="points_2d must be"): + backend.fit_circle_least_squares(pts) + + def test_fit_circle_noisy_stays_reasonable(self) -> None: + """fit_circle_least_squares is robust to small noise.""" + rng = np.random.default_rng(42) + pts = _make_circle_points(cx=1.0, cy=-1.0, r=3.0, n=80) + pts = pts + rng.normal(0, 0.02, pts.shape) + cx, cy, r, err = backend.fit_circle_least_squares(pts) + assert abs(cx - 1.0) < 0.1 + assert abs(cy - (-1.0)) < 0.1 + assert abs(r - 3.0) < 0.1 + + # --- find_cylinder_vertices ------------------------------------------------ + + def test_find_cylinder_vertices_all_on_surface(self) -> None: + """All points exactly on a cylinder surface are found.""" + theta = np.linspace(0, 2 * np.pi, 20, endpoint=False) + r, cx, cy = 3.0, 1.0, 2.0 + x, y = cx + r * np.cos(theta), cy + r * np.sin(theta) + verts = np.column_stack([np.zeros(20), x, y]) # axis=0 + idx = backend.find_cylinder_vertices(verts, np.array([cx, cy]), axis=0, radius=r, tolerance=0.01) + assert len(idx) == 20 + + def test_find_cylinder_vertices_none_found(self) -> None: + """No vertices within tolerance returns empty array.""" + verts = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]], dtype=np.float64) + idx = backend.find_cylinder_vertices(verts, np.array([100.0, 100.0]), axis=0, radius=5.0, tolerance=0.01) + assert len(idx) == 0 + + def test_find_cylinder_vertices_empty_input(self) -> None: + """Empty vertex array returns empty index array.""" + idx = backend.find_cylinder_vertices( + np.empty((0, 3), dtype=np.float64), np.array([0.0, 0.0]), axis=0, radius=3.0, tolerance=0.1 + ) + assert len(idx) == 0 + + def test_find_cylinder_vertices_bad_axis(self) -> None: + """Invalid axis raises ValueError.""" + with pytest.raises(ValueError, match="axis"): + backend.find_cylinder_vertices( + np.ones((5, 3)), np.array([0.0, 0.0]), axis=5, radius=1.0, tolerance=0.1 + ) + + def test_find_cylinder_vertices_bad_center_shape(self) -> None: + """center_yz with 3 elements raises ValueError.""" + with pytest.raises(ValueError, match="center_yz must have 2"): + backend.find_cylinder_vertices( + np.ones((5, 3)), np.array([0.0, 0.0, 0.0]), axis=0, radius=1.0, tolerance=0.1 + ) + + # --- scale_vertices_radially ----------------------------------------------- + + def test_scale_vertices_radially_reaches_new_radius(self) -> None: + """Scaled vertices end up at the target radius.""" + theta = np.linspace(0, 2 * np.pi, 10, endpoint=False) + verts = np.column_stack([np.zeros(10), 3.0 * np.cos(theta), 3.0 * np.sin(theta)]) + idx = np.arange(10, dtype=np.intp) + new_verts = backend.scale_vertices_radially(verts, idx, np.array([0.0, 0.0]), axis=0, new_radius=5.0) + actual_r = np.sqrt(new_verts[:, 1] ** 2 + new_verts[:, 2] ** 2) + np.testing.assert_allclose(actual_r, 5.0, atol=1e-10) + + def test_scale_vertices_radially_does_not_mutate_input(self) -> None: + """Original vertex array is never mutated.""" + verts = np.ones((6, 3), dtype=np.float64) * 3.0 + orig = verts.copy() + backend.scale_vertices_radially(verts, np.arange(6, dtype=np.intp), np.array([0.0, 0.0]), axis=0, new_radius=7.0) + assert np.allclose(verts, orig) + + def test_scale_vertices_radially_empty_indices_returns_copy(self) -> None: + """Empty index array returns a copy of the original vertices.""" + verts = np.ones((4, 3), dtype=np.float64) + new_verts = backend.scale_vertices_radially( + verts, np.array([], dtype=np.intp), np.array([0.0, 0.0]), axis=0, new_radius=5.0 + ) + assert np.allclose(new_verts, verts) + + def test_scale_vertices_radially_bad_axis(self) -> None: + """Invalid axis raises ValueError.""" + with pytest.raises(ValueError, match="axis"): + backend.scale_vertices_radially( + np.ones((4, 3)), np.arange(4, dtype=np.intp), np.array([0.0, 0.0]), axis=9, new_radius=1.0 + ) + + def test_scale_vertices_radially_preserves_axis_coordinate(self) -> None: + """The axis coordinate is unchanged after radial scaling.""" + z = np.linspace(-5, 5, 8) + x = 3.0 * np.cos(np.linspace(0, 2 * np.pi, 8, endpoint=False)) + y = 3.0 * np.sin(np.linspace(0, 2 * np.pi, 8, endpoint=False)) + verts = np.column_stack([x, y, z]) # axis=2 + idx = np.arange(8, dtype=np.intp) + new_verts = backend.scale_vertices_radially(verts, idx, np.array([0.0, 0.0]), axis=2, new_radius=7.0) + assert np.allclose(new_verts[:, 2], z) + + # --- cross_section_circles (smoke tests) ----------------------------------- + + def test_cross_section_circles_returns_list(self) -> None: + circles = backend.cross_section_circles(_make_cube_mesh(), axis=0, num_planes=5) + assert isinstance(circles, list) + + def test_cross_section_circles_empty_mesh(self) -> None: + circles = backend.cross_section_circles(_make_empty_mesh(), axis=0, num_planes=5) + assert circles == [] + + def test_cross_section_circles_bad_axis(self) -> None: + with pytest.raises(ValueError, match="axis"): + backend.cross_section_circles(_make_cube_mesh(), axis=5, num_planes=5) + + +# Convenience function used in multiple TestBackend tests +def compute_mesh_stats_cube() -> dict: + return backend.compute_mesh_stats(_make_cube_mesh(size=10.0)) + + +# =========================================================================== +# TestInspector -- InspectParams, DetectedHole, inspect_mesh, compare_meshes +# =========================================================================== + + +class TestInspector: + """Tests for cli_anything.threemf.core.inspector.""" + + # --- InspectParams --------------------------------------------------------- + + def test_inspect_params_default_values(self) -> None: + p = inspector.InspectParams() + assert p.num_planes == 20 + assert p.min_hole_diameter == 0.5 + assert p.min_confidence == 0.7 + assert p.axis == 0 + + def test_inspect_params_custom_values(self) -> None: + p = inspector.InspectParams(num_planes=10, min_hole_diameter=1.0, min_confidence=0.8, axis=2) + assert p.num_planes == 10 + assert p.min_confidence == 0.8 + assert p.axis == 2 + + def test_inspect_params_is_frozen(self) -> None: + p = inspector.InspectParams() + with pytest.raises(Exception): + p.num_planes = 99 # type: ignore[misc] + + # --- DetectedHole ---------------------------------------------------------- + + def test_detected_hole_stores_all_fields(self) -> None: + h = inspector.DetectedHole( + hole_id=5, center=(1.0, 2.0), diameter=3.5, + axis_min=-5.0, axis_max=5.0, axis=1, confidence=0.85, vertex_count=30, + ) + assert h.hole_id == 5 + assert h.center == (1.0, 2.0) + assert h.diameter == 3.5 + assert h.axis == 1 + assert h.confidence == 0.85 + assert h.vertex_count == 30 + + def test_detected_hole_is_frozen(self) -> None: + h = _make_fake_hole() + with pytest.raises(Exception): + h.hole_id = 99 # type: ignore[misc] + + # --- inspect_mesh ---------------------------------------------------------- + + def test_inspect_mesh_returns_list(self) -> None: + assert isinstance(inspector.inspect_mesh(_make_cube_mesh()), list) + + def test_inspect_mesh_empty_mesh_returns_empty_list(self) -> None: + assert inspector.inspect_mesh(_make_empty_mesh()) == [] + + def test_inspect_mesh_default_params(self) -> None: + """inspect_mesh runs with None params (uses defaults).""" + result = inspector.inspect_mesh(_make_cube_mesh(), None) + assert isinstance(result, list) + + def test_inspect_mesh_custom_params(self) -> None: + """inspect_mesh accepts custom InspectParams without error.""" + params = inspector.InspectParams(num_planes=5, min_hole_diameter=1.0) + result = inspector.inspect_mesh(_make_cube_mesh(), params) + assert isinstance(result, list) + + def test_inspect_mesh_result_items_are_detected_holes(self) -> None: + """Any items in the result are DetectedHole instances.""" + for item in inspector.inspect_mesh(_make_cube_mesh()): + assert isinstance(item, inspector.DetectedHole) + + def test_inspect_mesh_sorted_by_hole_id(self) -> None: + """Results are sorted by hole_id in ascending order.""" + result = inspector.inspect_mesh(_make_cube_mesh()) + ids = [h.hole_id for h in result] + assert ids == sorted(ids) + + # --- compare_meshes -------------------------------------------------------- + + def test_compare_meshes_identical_zero_diff(self) -> None: + mesh = _make_cube_mesh() + result = inspector.compare_meshes(mesh, mesh) + assert result["vertices"]["diff"] == 0 + assert result["faces"]["diff"] == 0 + assert result["volume"]["diff"] == pytest.approx(0.0, abs=1e-6) + + def test_compare_meshes_has_required_top_level_keys(self) -> None: + result = inspector.compare_meshes(_make_cube_mesh(), _make_cube_mesh()) + for key in ("vertices", "faces", "volume", "watertight"): + assert key in result + + def test_compare_meshes_sub_dicts_have_file1_file2_diff(self) -> None: + result = inspector.compare_meshes(_make_cube_mesh(), _make_cube_mesh()) + for key in ("vertices", "faces", "volume"): + sub = result[key] + assert "file1" in sub and "file2" in sub and "diff" in sub + + def test_compare_meshes_watertight_has_file1_and_file2(self) -> None: + result = inspector.compare_meshes(_make_cube_mesh(), _make_cube_mesh()) + assert "file1" in result["watertight"] and "file2" in result["watertight"] + + def test_compare_meshes_vertex_counts_match_meshes(self) -> None: + mesh_a = _make_cube_mesh(size=10.0) + mesh_b = _make_cube_mesh(size=5.0) + result = inspector.compare_meshes(mesh_a, mesh_b) + assert result["vertices"]["file1"] == mesh_a.vertices.shape[0] + assert result["vertices"]["file2"] == mesh_b.vertices.shape[0] + + def test_compare_meshes_different_volumes(self) -> None: + """Cubes of different sizes have different volumes.""" + mesh_a = _make_cube_mesh(size=10.0) + mesh_b = _make_cube_mesh(size=20.0) + result = inspector.compare_meshes(mesh_a, mesh_b) + assert result["volume"]["file2"] > result["volume"]["file1"] + + # --- get_mesh_info --------------------------------------------------------- + + def test_get_mesh_info_returns_dict(self) -> None: + """get_mesh_info returns a dict.""" + result = inspector.get_mesh_info(_make_cube_mesh()) + assert isinstance(result, dict) + + def test_get_mesh_info_has_object_id_and_name(self) -> None: + """get_mesh_info includes object_id and name fields.""" + mesh = _make_cube_mesh() + result = inspector.get_mesh_info(mesh) + assert result["object_id"] == mesh.object_id + assert result["name"] == mesh.name + + def test_get_mesh_info_vertex_and_triangle_counts(self) -> None: + """get_mesh_info reports correct vertex and triangle counts.""" + mesh = _make_cube_mesh() + result = inspector.get_mesh_info(mesh) + assert result["num_vertices"] == 8 + assert result["num_triangles"] == 12 + + def test_get_mesh_info_includes_stats_keys(self) -> None: + """get_mesh_info includes all keys from compute_mesh_stats.""" + result = inspector.get_mesh_info(_make_cube_mesh()) + for key in ("vertex_count", "face_count", "bounding_box", "watertight", + "volume_mm3", "surface_area_mm2"): + assert key in result + + # --- _validate_mesh error branches ----------------------------------------- + + def test_validate_mesh_raises_for_none(self) -> None: + """compare_meshes propagates ValueError when mesh_data is None.""" + with pytest.raises((ValueError, AttributeError)): + inspector.compare_meshes(None, _make_cube_mesh()) # type: ignore[arg-type] + + # --- inspect_mesh flat-mesh early return ----------------------------------- + + def test_inspect_mesh_flat_mesh_returns_empty(self) -> None: + """A mesh with all vertices on a single plane (zero span along axis) returns [].""" + # All vertices share the same X coordinate -> span along X=0 is zero + v = np.array( + [[0.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [0.0, 1.0, 1.0]], + dtype=np.float64, + ) + t = np.array([[0, 1, 2], [1, 3, 2]], dtype=np.int32) + mesh = MeshData(object_id="1", name="flat", vertices=v, triangles=t) + params = inspector.InspectParams(axis=0) + result = inspector.inspect_mesh(mesh, params) + assert result == [] + + # --- _group_circles: concentric circles must not merge -------------------- + + def test_group_circles_keeps_concentric_different_radii_separate(self) -> None: + """Concentric circles of very different radii are distinct holes. + + Regression: grouping by centre only merged a hole's inner circle with + the body's outer contour (both centred on the axis) and averaged their + radii into a meaningless diameter. + """ + circles = [ + {"center": (0.0, 0.0), "radius": 4.0, "fit_error": 0.0, "level": z} + for z in (-2.0, -1.0, 0.0, 1.0, 2.0) + ] + [ + {"center": (0.0, 0.0), "radius": 20.0, "fit_error": 0.0, "level": z} + for z in (-2.0, -1.0, 0.0, 1.0, 2.0) + ] + groups = inspector._group_circles(circles) + mean_radii = sorted(round(float(np.mean([c["radius"] for c in g])), 1) for g in groups) + assert mean_radii == [4.0, 20.0] + + def test_group_circles_merges_same_hole_across_planes(self) -> None: + """Circles from one hole (small centre/radius jitter) stay in one group.""" + circles = [ + {"center": (0.01 * i, -0.01 * i), "radius": 4.0 + 0.002 * i, + "fit_error": 0.0, "level": float(i)} + for i in range(6) + ] + groups = inspector._group_circles(circles) + assert len(groups) == 1 + + def test_inspect_concentric_reports_only_true_inner_hole(self) -> None: + """A washer yields exactly its inner Ø8 hole -- not the merged Ø24, nor the Ø40 outer wall.""" + mesh = _make_washer_mesh(r_inner=4.0, r_outer=20.0, height=10.0) + holes = inspector.inspect_mesh(mesh, InspectParams(axis=2)) + diameters = sorted(round(h.diameter, 1) for h in holes) + assert diameters == [8.0] # only the inner hole + assert not any(abs(h.diameter - 40.0) < 1.0 for h in holes) # outer wall rejected + assert not any(abs(h.diameter - 24.0) < 1.0 for h in holes) # not the merged blob + + def test_inspect_rejects_solid_cylinder_surface(self) -> None: + """A solid cylinder's outer surface is a boundary, not a hole -- it must not be reported.""" + import trimesh + + tm = trimesh.creation.cylinder(radius=3.0, height=10.0, sections=48) + mesh = MeshData( + object_id="1", name="cyl", + vertices=np.asarray(tm.vertices, dtype=np.float64), + triangles=np.asarray(tm.faces, dtype=np.int32), + ) + assert inspector.inspect_mesh(mesh, InspectParams(axis=2)) == [] + + def test_inspect_rejects_boss_on_wider_base(self) -> None: + """A solid boss on a wider plate faces outward -- not a hole.""" + mesh = _make_boss_on_plate_mesh(boss_radius=5.0, boss_height=20.0) + holes = inspector.inspect_mesh(mesh, InspectParams(axis=2)) + assert not any(abs(h.diameter - 10.0) < 1.0 for h in holes) + + def test_inspect_keeps_blind_hole(self) -> None: + """A blind cylindrical pocket faces inward and must still be detected as a hole.""" + trimesh = pytest.importorskip("trimesh") + box = trimesh.creation.box(extents=(30, 30, 20)) + drill = trimesh.creation.cylinder(radius=4.0, height=18.0, sections=48) + drill.apply_translation([0, 0, 1]) # pocket from the top face, leaving a ~2mm floor + try: + blind = box.difference(drill) + except Exception: + pytest.skip("no boolean backend available for blind-hole fixture") + if not getattr(blind, "is_watertight", False) or len(blind.faces) <= len(box.faces): + pytest.skip("boolean backend produced no pocket") + mesh = MeshData( + object_id="1", name="blind", + vertices=np.asarray(blind.vertices, dtype=np.float64), + triangles=np.asarray(blind.faces, dtype=np.int32), + ) + holes = inspector.inspect_mesh(mesh, InspectParams(axis=2, min_confidence=0.5)) + assert any(abs(h.diameter - 8.0) < 1.0 for h in holes) + + def test_inspect_through_hole_axial_extent_spans_part(self) -> None: + """Axial extent comes from the wall vertices, spanning the full part thickness. + + Regression: the extent was taken from the inset sampling planes, so a + through hole's rim vertices fell outside the band and resize moved + nothing (vertex_count was 0). + """ + mesh = _make_washer_mesh(r_inner=4.0, r_outer=20.0, height=10.0) + holes = [h for h in inspector.inspect_mesh(mesh, InspectParams(axis=2)) + if abs(h.diameter - 8.0) < 1.0] + assert holes and holes[0].vertex_count > 0 + assert holes[0].axis_max - holes[0].axis_min == pytest.approx(10.0, abs=0.2) + + def test_wall_axial_extent_ignores_feature_outside_window(self) -> None: + """A coaxial same-radius feature outside the search window must not stretch the extent. + + Regression: the extent was read from a global radius mask, so an + unrelated same-radius ring elsewhere on the axis inflated axis_min/max + and made resize move unrelated vertices. + """ + r, n = 4.0, 24 + theta = np.linspace(0, 2 * np.pi, n, endpoint=False) + ring = np.column_stack([r * np.cos(theta), r * np.sin(theta)]) + + def ring_at_z(z: float) -> np.ndarray: + return np.column_stack([ring[:, 0], ring[:, 1], np.full(n, z)]) + + # hole wall spans z in [0, 10]; an unrelated same-radius ring sits at z=50 + verts = np.vstack([ring_at_z(0.0), ring_at_z(10.0), ring_at_z(50.0)]) + lo, hi = inspector._wall_axial_extent( + verts, (0.0, 0.0), r, 2, (0, 1), search_lo=-0.5, search_hi=10.5, + ) + assert (lo, hi) == pytest.approx((0.0, 10.0)) + + +# =========================================================================== +# TestRepair -- repair_mesh, individual repair functions, fix_normals +# =========================================================================== + + +class TestRepair: + """Tests for cli_anything.threemf.core.repair.""" + + # --- repair_mesh ----------------------------------------------------------- + + def test_repair_mesh_returns_tuple_of_mesh_and_dict(self) -> None: + result = repair.repair_mesh(_make_cube_mesh()) + assert isinstance(result, tuple) and len(result) == 2 + assert isinstance(result[0], MeshData) + assert isinstance(result[1], dict) + + def test_repair_mesh_report_has_required_keys(self) -> None: + _, report = repair.repair_mesh(_make_cube_mesh()) + for key in ( + "vertices_merged", "degenerate_faces_removed", + "unreferenced_vertices_removed", "final_vertex_count", "final_triangle_count", + ): + assert key in report + + def test_repair_clean_mesh_zero_changes(self) -> None: + _, report = repair.repair_mesh(_make_cube_mesh()) + assert report["vertices_merged"] == 0 + assert report["degenerate_faces_removed"] == 0 + assert report["unreferenced_vertices_removed"] == 0 + + def test_repair_final_counts_match_returned_mesh(self) -> None: + repaired, report = repair.repair_mesh(_make_cube_mesh()) + assert report["final_vertex_count"] == repaired.vertices.shape[0] + assert report["final_triangle_count"] == repaired.triangles.shape[0] + + def test_repair_empty_mesh_returns_same_object(self) -> None: + m = _make_empty_mesh() + repaired, report = repair.repair_mesh(m) + assert repaired is m + assert report["vertices_merged"] == 0 + + def test_repair_merges_duplicate_vertices(self) -> None: + m = _make_mesh_with_duplicates() + _, report = repair.repair_mesh(m) + assert report["vertices_merged"] >= 1 + + def test_repair_removes_degenerate_faces(self) -> None: + m = _make_mesh_with_degenerate() + _, report = repair.repair_mesh(m) + assert report["degenerate_faces_removed"] >= 1 + + def test_repair_full_pipeline_vertex_count_non_increasing(self) -> None: + """After repair, vertex count is <= original.""" + m = _make_mesh_with_duplicates() + repaired, _ = repair.repair_mesh(m) + assert repaired.vertices.shape[0] <= m.vertices.shape[0] + + # --- merge_duplicate_vertices ---------------------------------------------- + + def test_merge_duplicate_vertices_removes_one(self) -> None: + v = np.array( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + dtype=np.float64, + ) + t = np.array([[0, 1, 3]], dtype=np.int32) + _, _, count = repair.merge_duplicate_vertices(v, t) + assert count == 1 + + def test_merge_duplicate_vertices_no_duplicates_count_zero(self) -> None: + m = _make_cube_mesh() + _, _, count = repair.merge_duplicate_vertices(m.vertices, m.triangles) + assert count == 0 + + def test_merge_duplicate_vertices_empty(self) -> None: + _, _, count = repair.merge_duplicate_vertices( + np.empty((0, 3), dtype=np.float64), np.empty((0, 3), dtype=np.int32) + ) + assert count == 0 + + def test_merge_duplicate_vertices_cube_duplicates(self) -> None: + m = _make_mesh_with_duplicates() + v2, _, count = repair.merge_duplicate_vertices(m.vertices, m.triangles) + assert count == 2 + assert v2.shape[0] == m.vertices.shape[0] - 2 + + # --- remove_degenerate_faces ----------------------------------------------- + + def test_remove_degenerate_faces_removes_three(self) -> None: + t = np.array( + [[0, 1, 2], [0, 0, 2], [1, 2, 1], [3, 4, 3], [5, 6, 7]], + dtype=np.int32, + ) + t2, count = repair.remove_degenerate_faces(t) + assert count == 3 + assert t2.shape[0] == 2 + + def test_remove_degenerate_faces_all_valid(self) -> None: + t = np.array([[0, 1, 2], [3, 4, 5]], dtype=np.int32) + _, count = repair.remove_degenerate_faces(t) + assert count == 0 + + def test_remove_degenerate_faces_empty(self) -> None: + t2, count = repair.remove_degenerate_faces(np.empty((0, 3), dtype=np.int32)) + assert count == 0 and t2.shape[0] == 0 + + def test_remove_degenerate_faces_cube_with_one(self) -> None: + m = _make_mesh_with_degenerate() + _, count = repair.remove_degenerate_faces(m.triangles) + assert count == 1 + + # --- remove_unreferenced_vertices ------------------------------------------ + + def test_remove_unreferenced_removes_one(self) -> None: + m = _make_cube_mesh() + extra = np.array([[99.0, 99.0, 99.0]], dtype=np.float64) + v = np.vstack([m.vertices, extra]) + _, _, count = repair.remove_unreferenced_vertices(v, m.triangles) + assert count == 1 + + def test_remove_unreferenced_all_referenced_count_zero(self) -> None: + m = _make_cube_mesh() + _, _, count = repair.remove_unreferenced_vertices(m.vertices, m.triangles) + assert count == 0 + + def test_remove_unreferenced_no_triangles_removes_all(self) -> None: + v = np.zeros((4, 3), dtype=np.float64) + _, _, count = repair.remove_unreferenced_vertices(v, np.empty((0, 3), dtype=np.int32)) + assert count == 4 + + def test_remove_unreferenced_remaps_indices_validly(self) -> None: + """After removal, triangle indices are still valid for the new vertex array.""" + v = np.array( + [[0.0, 0.0, 0.0], [99.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + dtype=np.float64, + ) + t = np.array([[0, 2, 3]], dtype=np.int32) + v2, t2, _ = repair.remove_unreferenced_vertices(v, t) + assert t2.max() < v2.shape[0] + + def test_remove_unreferenced_empty_vertices(self) -> None: + _, _, count = repair.remove_unreferenced_vertices( + np.empty((0, 3), dtype=np.float64), np.empty((0, 3), dtype=np.int32) + ) + assert count == 0 + + # --- fix_normals ----------------------------------------------------------- + + def test_fix_normals_returns_mesh_data(self) -> None: + assert isinstance(repair.fix_normals(_make_cube_mesh()), MeshData) + + def test_fix_normals_preserves_vertex_count(self) -> None: + mesh = _make_cube_mesh() + assert repair.fix_normals(mesh).vertices.shape[0] == mesh.vertices.shape[0] + + def test_fix_normals_preserves_face_count(self) -> None: + mesh = _make_cube_mesh() + assert repair.fix_normals(mesh).triangles.shape[0] == mesh.triangles.shape[0] + + def test_fix_normals_empty_mesh_passthrough(self) -> None: + """fix_normals returns the same object for an empty mesh.""" + m = _make_empty_mesh() + assert repair.fix_normals(m) is m + + +# =========================================================================== +# TestModifier -- resize_holes, resize_single_hole +# =========================================================================== + + +class TestModifier: + """Tests for cli_anything.threemf.core.modifier.""" + + # --- resize_holes validation ----------------------------------------------- + + def test_resize_holes_bad_mesh_index_raises(self) -> None: + with pytest.raises(ValueError, match="out of range"): + resize_holes(_make_threemf_data(), [0], 5.0, mesh_index=99) + + def test_resize_holes_negative_diameter_raises(self) -> None: + with pytest.raises(ValueError, match="positive"): + resize_holes(_make_threemf_data(), [0], -1.0, mesh_index=0) + + def test_resize_holes_zero_diameter_raises(self) -> None: + with pytest.raises(ValueError, match="positive"): + resize_holes(_make_threemf_data(), [0], 0.0, mesh_index=0) + + def test_resize_holes_empty_hole_ids_raises(self) -> None: + with pytest.raises(ValueError, match="empty"): + resize_holes(_make_threemf_data(), [], 5.0, mesh_index=0) + + def test_resize_holes_unknown_hole_id_raises(self) -> None: + with pytest.raises(ValueError, match="Unknown hole_ids"): + resize_holes(_make_threemf_data(), [999], 5.0, mesh_index=0) + + def test_resize_holes_none_data_raises(self) -> None: + with pytest.raises((ValueError, AttributeError)): + resize_holes(None, [0], 5.0, mesh_index=0) # type: ignore[arg-type] + + # --- resize_single_hole ---------------------------------------------------- + + def test_resize_single_hole_returns_tuple(self) -> None: + result = resize_single_hole(_make_cube_mesh(), _make_fake_hole(), 4.0) + assert isinstance(result, tuple) and len(result) == 2 + + def test_resize_single_hole_first_element_is_mesh_data(self) -> None: + new_mesh, _ = resize_single_hole(_make_cube_mesh(), _make_fake_hole(), 4.0) + assert isinstance(new_mesh, MeshData) + + def test_resize_single_hole_report_has_required_keys(self) -> None: + _, report = resize_single_hole(_make_cube_mesh(), _make_fake_hole(), 4.0) + for key in ("hole_id", "old_diameter", "new_diameter", "vertices_moved"): + assert key in report + + def test_resize_single_hole_report_new_diameter_correct(self) -> None: + _, report = resize_single_hole(_make_cube_mesh(), _make_fake_hole(diameter=3.0), 6.0) + assert report["new_diameter"] == pytest.approx(6.0, abs=1e-4) + + def test_resize_single_hole_report_old_diameter_correct(self) -> None: + _, report = resize_single_hole(_make_cube_mesh(), _make_fake_hole(diameter=3.5, hole_id=2), 5.0) + assert report["old_diameter"] == 3.5 + assert report["hole_id"] == 2 + + def test_resize_single_hole_negative_diameter_raises(self) -> None: + with pytest.raises(ValueError, match="positive"): + resize_single_hole(_make_cube_mesh(), _make_fake_hole(), -5.0) + + def test_resize_single_hole_none_mesh_raises(self) -> None: + with pytest.raises((ValueError, AttributeError)): + resize_single_hole(None, _make_fake_hole(), 5.0) # type: ignore[arg-type] + + def test_resize_single_hole_no_wall_vertices_returns_unchanged_mesh(self) -> None: + """If no wall vertices match (hole centre far away), mesh is unchanged.""" + mesh = _make_cube_mesh() + hole = _make_fake_hole(center=(500.0, 500.0)) + new_mesh, report = resize_single_hole(mesh, hole, 4.0) + assert report["vertices_moved"] == 0 + assert new_mesh is mesh + + def test_resize_single_hole_does_not_mutate_original_vertices(self) -> None: + """resize_single_hole never mutates the input MeshData.""" + n = 20 + theta = np.linspace(0, 2 * np.pi, n, endpoint=False) + r = 1.0 + verts = np.column_stack([np.zeros(n), r * np.cos(theta), r * np.sin(theta)]) + tris = np.array([[i, (i + 1) % n, (i + 2) % n] for i in range(n - 2)], dtype=np.int32) + mesh = MeshData(object_id="c", name="c", vertices=verts, triangles=tris) + original = mesh.vertices.copy() + hole = inspector.DetectedHole( + hole_id=0, center=(0.0, 0.0), diameter=r * 2, + axis_min=-0.1, axis_max=0.1, axis=0, + confidence=0.9, vertex_count=n, + ) + resize_single_hole(mesh, hole, target_diameter=3.0) + np.testing.assert_array_equal(mesh.vertices, original) + + # --- resize_holes respects detection params (axis parity with inspect) ----- + + def test_resize_holes_default_params_miss_non_default_axis_hole(self) -> None: + """Without params, resize re-detects on axis 0 and can't see a Z-axis hole.""" + data = _make_threemf_data(_make_washer_mesh(r_inner=4.0, r_outer=20.0)) + with pytest.raises(ValueError, match="Unknown hole_ids"): + resize_holes(data, [0], 12.0, mesh_index=0) + + def test_resize_holes_with_axis_params_resizes_non_default_axis_hole(self) -> None: + """With matching params (axis=2), the hole is detected and its wall vertices move.""" + data = _make_threemf_data(_make_washer_mesh(r_inner=4.0, r_outer=20.0)) + new_data, changes = resize_holes( + data, [0], 12.0, mesh_index=0, params=InspectParams(axis=2), + ) + assert changes and changes[0]["vertices_moved"] > 0 + # the modified mesh is a genuinely new object (resize actually happened) + assert new_data.meshes[0] is not data.meshes[0] diff --git a/3MF/agent-harness/cli_anything/threemf/threemf_cli.py b/3MF/agent-harness/cli_anything/threemf/threemf_cli.py new file mode 100644 index 0000000..9b3df6a --- /dev/null +++ b/3MF/agent-harness/cli_anything/threemf/threemf_cli.py @@ -0,0 +1,432 @@ +#!/usr/bin/env python3 +"""3MF CLI — Mesh geometry editor for 3D printing files. + +Detect and resize cylindrical holes, repair meshes, compare 3MF files. + +Usage: + # One-shot commands + cli-anything-3mf info model.3mf + cli-anything-3mf inspect model.3mf + cli-anything-3mf resize model.3mf --hole 0 --hole 1 --diameter 4.2 -o output.3mf + cli-anything-3mf --json inspect model.3mf + + # Interactive REPL + cli-anything-3mf +""" + +import sys +import os +import json +import shlex +import click +from typing import Optional + +from cli_anything.threemf.core import parser +from cli_anything.threemf.core import inspector +from cli_anything.threemf.core import modifier +from cli_anything.threemf.core import repair as repair_mod +from cli_anything.threemf.utils import threemf_backend as backend + +# ── Global state ──────────────────────────────────────────────── +_json_output = False +_repl_mode = False + + +def output(data, message: str = ""): + """Print data as JSON or human-readable text.""" + if _json_output: + click.echo(json.dumps(data, indent=2, default=str)) + else: + if message: + click.echo(message) + if isinstance(data, dict): + _print_dict(data) + elif isinstance(data, list): + _print_list(data) + else: + click.echo(str(data)) + + +def _print_dict(d: dict, indent: int = 0): + prefix = " " * indent + for k, v in d.items(): + if isinstance(v, dict): + click.echo(f"{prefix}{k}:") + _print_dict(v, indent + 1) + elif isinstance(v, list): + click.echo(f"{prefix}{k}:") + _print_list(v, indent + 1) + else: + click.echo(f"{prefix}{k}: {v}") + + +def _print_list(items: list, indent: int = 0): + prefix = " " * indent + for i, item in enumerate(items): + if isinstance(item, dict): + click.echo(f"{prefix}[{i}]") + _print_dict(item, indent + 1) + else: + click.echo(f"{prefix}- {item}") + + +def handle_error(func): + """Decorator: catch errors and format output.""" + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except (RuntimeError, FileNotFoundError, FileExistsError) as e: + if _json_output: + click.echo(json.dumps({"error": str(e), "type": type(e).__name__})) + else: + click.echo(f"Error: {e}", err=True) + if not _repl_mode: + sys.exit(1) + except (ValueError, IndexError) as e: + if _json_output: + click.echo(json.dumps({"error": str(e), "type": type(e).__name__})) + else: + click.echo(f"Error: {e}", err=True) + if not _repl_mode: + sys.exit(1) + wrapper.__name__ = func.__name__ + wrapper.__doc__ = func.__doc__ + return wrapper + + +# ── Main CLI Group ────────────────────────────────────────────── +@click.group(invoke_without_command=True) +@click.option("--json", "use_json", is_flag=True, help="Output as JSON") +@click.pass_context +def cli(ctx, use_json): + """3MF CLI — Mesh geometry editor for 3D printing files. + + Run without a subcommand to enter interactive REPL mode. + """ + global _json_output + _json_output = use_json + + if ctx.invoked_subcommand is None: + ctx.invoke(repl) + + +# ── info command ──────────────────────────────────────────────── +@cli.command() +@click.argument("file", type=click.Path(exists=True)) +@handle_error +def info(file): + """Show mesh info (vertices, faces, bounds, watertight, volume).""" + data = parser.parse_3mf(file) + objects = [] + for mesh in data.meshes: + stats = backend.compute_mesh_stats(mesh) + stats["id"] = mesh.object_id + stats["name"] = mesh.name + objects.append(stats) + result = { + "file": os.path.basename(file), + "unit": data.unit, + "objects": objects, + } + if not _json_output: + click.echo(f"File: {os.path.basename(file)} Unit: {data.unit}") + click.echo() + for obj in objects: + click.echo(f" Object {obj['id']}: {obj['name']}") + click.echo(f" Vertices: {obj['vertex_count']}") + click.echo(f" Faces: {obj['face_count']}") + bb = obj["bounding_box"] + click.echo(f" Bounding box: [{bb['min'][0]:.2f}, {bb['min'][1]:.2f}, {bb['min'][2]:.2f}]" + f" to [{bb['max'][0]:.2f}, {bb['max'][1]:.2f}, {bb['max'][2]:.2f}]") + dims = bb["size"] + click.echo(f" Dimensions: {dims[0]:.2f} x {dims[1]:.2f} x {dims[2]:.2f} mm") + click.echo(f" Watertight: {obj['watertight']}") + vol = obj['volume_mm3'] + click.echo(f" Volume: {vol:.2f} mm3" if vol is not None else " Volume: N/A (not watertight)") + click.echo(f" Surface area: {obj['surface_area_mm2']:.2f} mm2") + else: + output(result) + + +# ── inspect command ───────────────────────────────────────────── +@cli.command() +@click.argument("file", type=click.Path(exists=True)) +@click.option("--planes", "-n", type=int, default=20, help="Number of cross-section planes") +@click.option("--min-diameter", type=float, default=0.5, help="Minimum hole diameter (mm)") +@click.option("--min-confidence", type=float, default=0.7, help="Minimum detection confidence") +@click.option("--axis", "-a", type=int, default=0, help="Hole axis: 0=X, 1=Y, 2=Z") +@click.option("--mesh", "-m", type=int, default=0, help="Mesh object index") +@handle_error +def inspect(file, planes, min_diameter, min_confidence, axis, mesh): + """Detect and list all cylindrical holes.""" + data = parser.parse_3mf(file) + if mesh >= len(data.meshes): + raise ValueError(f"Mesh index {mesh} out of range (0..{len(data.meshes)-1})") + + mesh_data = data.meshes[mesh] + params = inspector.InspectParams( + num_planes=planes, + min_hole_diameter=min_diameter, + min_confidence=min_confidence, + axis=axis, + ) + holes = inspector.inspect_mesh(mesh_data, params) + + from dataclasses import asdict + holes_data = [asdict(h) for h in holes] + result = { + "file": os.path.basename(file), + "object": mesh_data.name, + "hole_count": len(holes), + "holes": holes_data, + } + + if not _json_output: + click.echo(f"File: {os.path.basename(file)} Object: {mesh_data.name}") + click.echo(f"Detected {len(holes)} hole(s):") + click.echo() + axis_labels = {0: "X", 1: "Y", 2: "Z"} + for h in holes: + click.echo(f" Hole {h.hole_id}:") + click.echo(f" Diameter: {h.diameter:.3f} mm") + click.echo(f" Center: ({h.center[0]:.3f}, {h.center[1]:.3f})") + click.echo(f" Axis: {axis_labels.get(h.axis, '?')}" + f" [{h.axis_min:.2f} .. {h.axis_max:.2f}]") + click.echo(f" Confidence: {h.confidence:.2f}") + click.echo(f" Vertices: {h.vertex_count}") + else: + output(result) + + +# ── resize command ────────────────────────────────────────────── +@cli.command() +@click.argument("file", type=click.Path(exists=True)) +@click.option("--hole", "-h", "hole_ids", multiple=True, type=int, required=True, + help="Hole ID(s) to resize (from inspect output)") +@click.option("--diameter", "-d", type=float, required=True, help="Target diameter (mm)") +@click.option("--output", "-o", "output_path", type=str, required=True, help="Output file path") +@click.option("--planes", "-n", type=int, default=20, help="Number of cross-section planes (hole detection)") +@click.option("--min-diameter", type=float, default=0.5, help="Minimum hole diameter (mm)") +@click.option("--min-confidence", type=float, default=0.7, help="Minimum detection confidence") +@click.option("--axis", "-a", type=int, default=0, help="Hole axis: 0=X, 1=Y, 2=Z (must match inspect)") +@click.option("--mesh", "-m", type=int, default=0, help="Mesh object index") +@click.option("--overwrite", is_flag=True, help="Overwrite output if exists") +@handle_error +def resize(file, hole_ids, diameter, output_path, planes, min_diameter, min_confidence, axis, mesh, overwrite): + """Resize cylindrical holes to specified diameter. + + Hole IDs come from `inspect`. Pass the SAME detection options here as you + gave `inspect` (especially --axis), otherwise the IDs won't line up. + """ + if os.path.exists(output_path) and not overwrite: + raise FileExistsError(f"Output file exists: {output_path}. Use --overwrite to replace.") + if diameter <= 0: + raise ValueError("Diameter must be positive") + + data = parser.parse_3mf(file) + params = inspector.InspectParams( + num_planes=planes, + min_hole_diameter=min_diameter, + min_confidence=min_confidence, + axis=axis, + ) + new_data, changes = modifier.resize_holes( + data, list(hole_ids), diameter, mesh_index=mesh, params=params, + ) + + # Auto-repair after resize + target_mesh = new_data.meshes[mesh] + repaired, report = repair_mod.repair_mesh(target_mesh) + total_fixes = report["vertices_merged"] + report["degenerate_faces_removed"] + report["unreferenced_vertices_removed"] + if total_fixes > 0: + meshes_list = list(new_data.meshes) + meshes_list[mesh] = repaired + new_data = parser.ThreeMFData( + meshes=tuple(meshes_list), + unit=new_data.unit, + model_path=new_data.model_path, + metadata=new_data.metadata, + raw_entries=new_data.raw_entries, + source_path=new_data.source_path, + ) + + parser.write_3mf(new_data, output_path) + + result = { + "input": os.path.basename(file), + "output": os.path.basename(output_path), + "target_diameter": diameter, + "holes_resized": list(hole_ids), + "changes": changes, + "repairs": report, + } + + if not _json_output: + click.echo(f"Resized {len(hole_ids)} hole(s) to {diameter}mm") + for c in changes: + click.echo(f" Hole {c['hole_id']}: {c['old_diameter']:.3f}mm -> {c['new_diameter']:.3f}mm" + f" ({c['vertices_moved']} vertices)") + if total_fixes > 0: + click.echo(f" Auto-repair: {total_fixes} fixes applied") + click.echo(f"Saved: {output_path}") + else: + output(result) + + +# ── repair command ────────────────────────────────────────────── +@cli.command() +@click.argument("file", type=click.Path(exists=True)) +@click.option("--output", "-o", "output_path", type=str, required=True, help="Output file path") +@click.option("--mesh", "-m", type=int, default=0, help="Mesh object index") +@click.option("--overwrite", is_flag=True, help="Overwrite output if exists") +@handle_error +def repair(file, output_path, mesh, overwrite): + """Fix mesh issues (degenerate faces, duplicate vertices, normals).""" + if os.path.exists(output_path) and not overwrite: + raise FileExistsError(f"Output file exists: {output_path}. Use --overwrite to replace.") + + data = parser.parse_3mf(file) + if mesh >= len(data.meshes): + raise ValueError(f"Mesh index {mesh} out of range (0..{len(data.meshes)-1})") + + mesh_data = data.meshes[mesh] + repaired, report = repair_mod.repair_mesh(mesh_data) + + meshes_list = list(data.meshes) + meshes_list[mesh] = repaired + new_data = parser.ThreeMFData( + meshes=tuple(meshes_list), + unit=data.unit, + model_path=data.model_path, + metadata=data.metadata, + raw_entries=data.raw_entries, + source_path=data.source_path, + ) + parser.write_3mf(new_data, output_path) + + result = { + "input": os.path.basename(file), + "output": os.path.basename(output_path), + "repairs": report, + } + + if not _json_output: + total = report['vertices_merged'] + report['degenerate_faces_removed'] + report['unreferenced_vertices_removed'] + click.echo(f"Repaired: {os.path.basename(file)}") + click.echo(f" Duplicates merged: {report['vertices_merged']}") + click.echo(f" Degenerates removed: {report['degenerate_faces_removed']}") + click.echo(f" Unreferenced removed: {report['unreferenced_vertices_removed']}") + click.echo(f" Total fixes: {total}") + click.echo(f"Saved: {output_path}") + else: + output(result) + + +# ── compare command ───────────────────────────────────────────── +@cli.command() +@click.argument("file1", type=click.Path(exists=True)) +@click.argument("file2", type=click.Path(exists=True)) +@click.option("--mesh", "-m", type=int, default=0, help="Mesh object index") +@handle_error +def compare(file1, file2, mesh): + """Compare two 3MF files.""" + data1 = parser.parse_3mf(file1) + data2 = parser.parse_3mf(file2) + + if mesh >= len(data1.meshes) or mesh >= len(data2.meshes): + raise ValueError(f"Mesh index {mesh} out of range for one or both files") + + result = inspector.compare_meshes(data1.meshes[mesh], data2.meshes[mesh]) + result["file1"] = os.path.basename(file1) + result["file2"] = os.path.basename(file2) + + if not _json_output: + click.echo(f"Comparing: {os.path.basename(file1)} vs {os.path.basename(file2)}") + click.echo() + click.echo(f" {'':20s} {'File 1':>10s} {'File 2':>10s} {'Diff':>10s}") + click.echo(f" {'─' * 52}") + click.echo(f" {'Vertices':20s} {result['vertices']['file1']:>10d}" + f" {result['vertices']['file2']:>10d}" + f" {result['vertices']['diff']:>+10d}") + click.echo(f" {'Faces':20s} {result['faces']['file1']:>10d}" + f" {result['faces']['file2']:>10d}" + f" {result['faces']['diff']:>+10d}") + click.echo(f" {'Volume (mm3)':20s} {result['volume']['file1']:>10.1f}" + f" {result['volume']['file2']:>10.1f}" + f" {result['volume']['diff']:>+10.1f}") + click.echo(f" {'Watertight':20s} {str(result['watertight']['file1']):>10s}" + f" {str(result['watertight']['file2']):>10s}") + else: + output(result) + + +# ── REPL ──────────────────────────────────────────────────────── +@cli.command(hidden=True) +@handle_error +def repl(): + """Start interactive REPL session.""" + global _repl_mode + _repl_mode = True + + from cli_anything.threemf.utils.repl_skin import ReplSkin + skin = ReplSkin("3mf", version="1.0.0") + skin.print_banner() + + pt_session = skin.create_prompt_session() + + skin.help({ + "info ": "Show mesh statistics", + "inspect ": "Detect cylindrical holes", + "resize -h -d -o ": "Resize holes", + "repair -o ": "Fix mesh issues", + "compare ": "Compare two files", + "help": "Show this help", + "quit": "Exit REPL", + }) + + while True: + try: + user_input = skin.get_input(pt_session) + if not user_input: + continue + if user_input.lower() in ("quit", "exit", "q"): + skin.print_goodbye() + break + if user_input.lower() == "help": + skin.help({ + "info ": "Show mesh statistics", + "inspect ": "Detect cylindrical holes", + "resize -h -d -o ": "Resize holes", + "repair -o ": "Fix mesh issues", + "compare ": "Compare two files", + "help": "Show this help", + "quit": "Exit REPL", + }) + continue + + try: + args = shlex.split(user_input) + except ValueError as e: + skin.error(f"Parse error: {e}") + continue + + try: + cli(args, standalone_mode=False) + except SystemExit: + pass + except click.exceptions.UsageError as e: + skin.error(str(e)) + except Exception as e: + skin.error(str(e)) + + except (KeyboardInterrupt, EOFError): + skin.print_goodbye() + break + + +def main(): + cli() + + +if __name__ == "__main__": + main() diff --git a/3MF/agent-harness/cli_anything/threemf/utils/__init__.py b/3MF/agent-harness/cli_anything/threemf/utils/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/3MF/agent-harness/cli_anything/threemf/utils/__init__.py @@ -0,0 +1 @@ + diff --git a/3MF/agent-harness/cli_anything/threemf/utils/repl_skin.py b/3MF/agent-harness/cli_anything/threemf/utils/repl_skin.py new file mode 100644 index 0000000..c731234 --- /dev/null +++ b/3MF/agent-harness/cli_anything/threemf/utils/repl_skin.py @@ -0,0 +1,521 @@ +"""cli-anything REPL Skin — Unified terminal interface for all CLI harnesses. + +Copy this file into your CLI package at: + cli_anything//utils/repl_skin.py + +Usage: + from cli_anything..utils.repl_skin import ReplSkin + + skin = ReplSkin("shotcut", version="1.0.0") + skin.print_banner() # auto-detects skills/SKILL.md inside the package + prompt_text = skin.prompt(project_name="my_video.mlt", modified=True) + skin.success("Project saved") + skin.error("File not found") + skin.warning("Unsaved changes") + skin.info("Processing 24 clips...") + skin.status("Track 1", "3 clips, 00:02:30") + skin.table(headers, rows) + skin.print_goodbye() +""" + +import os +import sys + +# ── ANSI color codes (no external deps for core styling) ────────────── + +_RESET = "\033[0m" +_BOLD = "\033[1m" +_DIM = "\033[2m" +_ITALIC = "\033[3m" +_UNDERLINE = "\033[4m" + +# Brand colors +_CYAN = "\033[38;5;80m" # cli-anything brand cyan +_CYAN_BG = "\033[48;5;80m" +_WHITE = "\033[97m" +_GRAY = "\033[38;5;245m" +_DARK_GRAY = "\033[38;5;240m" +_LIGHT_GRAY = "\033[38;5;250m" + +# Software accent colors — each software gets a unique accent +_ACCENT_COLORS = { + "gimp": "\033[38;5;214m", # warm orange + "blender": "\033[38;5;208m", # deep orange + "inkscape": "\033[38;5;39m", # bright blue + "audacity": "\033[38;5;33m", # navy blue + "libreoffice": "\033[38;5;40m", # green + "obs_studio": "\033[38;5;55m", # purple + "kdenlive": "\033[38;5;69m", # slate blue + "shotcut": "\033[38;5;35m", # teal green +} +_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue + +# Status colors +_GREEN = "\033[38;5;78m" +_YELLOW = "\033[38;5;220m" +_RED = "\033[38;5;196m" +_BLUE = "\033[38;5;75m" +_MAGENTA = "\033[38;5;176m" + +# ── Brand icon ──────────────────────────────────────────────────────── + +# The cli-anything icon: a small colored diamond/chevron mark +_ICON = f"{_CYAN}{_BOLD}◆{_RESET}" +_ICON_SMALL = f"{_CYAN}▸{_RESET}" + +# ── Box drawing characters ──────────────────────────────────────────── + +_H_LINE = "─" +_V_LINE = "│" +_TL = "╭" +_TR = "╮" +_BL = "╰" +_BR = "╯" +_T_DOWN = "┬" +_T_UP = "┴" +_T_RIGHT = "├" +_T_LEFT = "┤" +_CROSS = "┼" + + +def _strip_ansi(text: str) -> str: + """Remove ANSI escape codes for length calculation.""" + import re + return re.sub(r"\033\[[^m]*m", "", text) + + +def _visible_len(text: str) -> int: + """Get visible length of text (excluding ANSI codes).""" + return len(_strip_ansi(text)) + + +class ReplSkin: + """Unified REPL skin for cli-anything CLIs. + + Provides consistent branding, prompts, and message formatting + across all CLI harnesses built with the cli-anything methodology. + """ + + def __init__(self, software: str, version: str = "1.0.0", + history_file: str | None = None, skill_path: str | None = None): + """Initialize the REPL skin. + + Args: + software: Software name (e.g., "gimp", "shotcut", "blender"). + version: CLI version string. + history_file: Path for persistent command history. + Defaults to ~/.cli-anything-/history + skill_path: Path to the SKILL.md file for agent discovery. + Auto-detected from the package's skills/ directory if not provided. + Displayed in banner for AI agents to know where to read skill info. + """ + self.software = software.lower().replace("-", "_") + self.display_name = software.replace("_", " ").title() + self.version = version + + # Auto-detect skill path from package layout: + # cli_anything//utils/repl_skin.py (this file) + # cli_anything//skills/SKILL.md (target) + if skill_path is None: + from pathlib import Path + _auto = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md" + if _auto.is_file(): + skill_path = str(_auto) + self.skill_path = skill_path + self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT) + + # History file + if history_file is None: + from pathlib import Path + hist_dir = Path.home() / f".cli-anything-{self.software}" + hist_dir.mkdir(parents=True, exist_ok=True) + self.history_file = str(hist_dir / "history") + else: + self.history_file = history_file + + # Detect terminal capabilities + self._color = self._detect_color_support() + + def _detect_color_support(self) -> bool: + """Check if terminal supports color.""" + if os.environ.get("NO_COLOR"): + return False + if os.environ.get("CLI_ANYTHING_NO_COLOR"): + return False + if not hasattr(sys.stdout, "isatty"): + return False + return sys.stdout.isatty() + + def _c(self, code: str, text: str) -> str: + """Apply color code if colors are supported.""" + if not self._color: + return text + return f"{code}{text}{_RESET}" + + # ── Banner ──────────────────────────────────────────────────────── + + def print_banner(self): + """Print the startup banner with branding.""" + inner = 54 + + def _box_line(content: str) -> str: + """Wrap content in box drawing, padding to inner width.""" + pad = inner - _visible_len(content) + vl = self._c(_DARK_GRAY, _V_LINE) + return f"{vl}{content}{' ' * max(0, pad)}{vl}" + + top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}") + bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}") + + # Title: ◆ cli-anything · Shotcut + icon = self._c(_CYAN + _BOLD, "◆") + brand = self._c(_CYAN + _BOLD, "cli-anything") + dot = self._c(_DARK_GRAY, "·") + name = self._c(self.accent + _BOLD, self.display_name) + title = f" {icon} {brand} {dot} {name}" + + ver = f" {self._c(_DARK_GRAY, f' v{self.version}')}" + tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}" + empty = "" + + # Skill path for agent discovery + skill_line = None + if self.skill_path: + skill_icon = self._c(_MAGENTA, "◇") + skill_label = self._c(_DARK_GRAY, " Skill:") + skill_path_display = self._c(_LIGHT_GRAY, self.skill_path) + skill_line = f" {skill_icon} {skill_label} {skill_path_display}" + + print(top) + print(_box_line(title)) + print(_box_line(ver)) + if skill_line: + print(_box_line(skill_line)) + print(_box_line(empty)) + print(_box_line(tip)) + print(bot) + print() + + # ── Prompt ──────────────────────────────────────────────────────── + + def prompt(self, project_name: str = "", modified: bool = False, + context: str = "") -> str: + """Build a styled prompt string for prompt_toolkit or input(). + + Args: + project_name: Current project name (empty if none open). + modified: Whether the project has unsaved changes. + context: Optional extra context to show in prompt. + + Returns: + Formatted prompt string. + """ + parts = [] + + # Icon + if self._color: + parts.append(f"{_CYAN}◆{_RESET} ") + else: + parts.append("> ") + + # Software name + parts.append(self._c(self.accent + _BOLD, self.software)) + + # Project context + if project_name or context: + ctx = context or project_name + mod = "*" if modified else "" + parts.append(f" {self._c(_DARK_GRAY, '[')}") + parts.append(self._c(_LIGHT_GRAY, f"{ctx}{mod}")) + parts.append(self._c(_DARK_GRAY, ']')) + + parts.append(self._c(_GRAY, " ❯ ")) + + return "".join(parts) + + def prompt_tokens(self, project_name: str = "", modified: bool = False, + context: str = ""): + """Build prompt_toolkit formatted text tokens for the prompt. + + Use with prompt_toolkit's FormattedText for proper ANSI handling. + + Returns: + list of (style, text) tuples for prompt_toolkit. + """ + accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff") + tokens = [] + + tokens.append(("class:icon", "◆ ")) + tokens.append(("class:software", self.software)) + + if project_name or context: + ctx = context or project_name + mod = "*" if modified else "" + tokens.append(("class:bracket", " [")) + tokens.append(("class:context", f"{ctx}{mod}")) + tokens.append(("class:bracket", "]")) + + tokens.append(("class:arrow", " ❯ ")) + + return tokens + + def get_prompt_style(self): + """Get a prompt_toolkit Style object matching the skin. + + Returns: + prompt_toolkit.styles.Style + """ + try: + from prompt_toolkit.styles import Style + except ImportError: + return None + + accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff") + + return Style.from_dict({ + "icon": "#5fdfdf bold", # cyan brand color + "software": f"{accent_hex} bold", + "bracket": "#585858", + "context": "#bcbcbc", + "arrow": "#808080", + # Completion menu + "completion-menu.completion": "bg:#303030 #bcbcbc", + "completion-menu.completion.current": f"bg:{accent_hex} #000000", + "completion-menu.meta.completion": "bg:#303030 #808080", + "completion-menu.meta.completion.current": f"bg:{accent_hex} #000000", + # Auto-suggest + "auto-suggest": "#585858", + # Bottom toolbar + "bottom-toolbar": "bg:#1c1c1c #808080", + "bottom-toolbar.text": "#808080", + }) + + # ── Messages ────────────────────────────────────────────────────── + + def success(self, message: str): + """Print a success message with green checkmark.""" + icon = self._c(_GREEN + _BOLD, "✓") + print(f" {icon} {self._c(_GREEN, message)}") + + def error(self, message: str): + """Print an error message with red cross.""" + icon = self._c(_RED + _BOLD, "✗") + print(f" {icon} {self._c(_RED, message)}", file=sys.stderr) + + def warning(self, message: str): + """Print a warning message with yellow triangle.""" + icon = self._c(_YELLOW + _BOLD, "⚠") + print(f" {icon} {self._c(_YELLOW, message)}") + + def info(self, message: str): + """Print an info message with blue dot.""" + icon = self._c(_BLUE, "●") + print(f" {icon} {self._c(_LIGHT_GRAY, message)}") + + def hint(self, message: str): + """Print a subtle hint message.""" + print(f" {self._c(_DARK_GRAY, message)}") + + def section(self, title: str): + """Print a section header.""" + print() + print(f" {self._c(self.accent + _BOLD, title)}") + print(f" {self._c(_DARK_GRAY, _H_LINE * len(title))}") + + # ── Status display ──────────────────────────────────────────────── + + def status(self, label: str, value: str): + """Print a key-value status line.""" + lbl = self._c(_GRAY, f" {label}:") + val = self._c(_WHITE, f" {value}") + print(f"{lbl}{val}") + + def status_block(self, items: dict[str, str], title: str = ""): + """Print a block of status key-value pairs. + + Args: + items: Dict of label -> value pairs. + title: Optional title for the block. + """ + if title: + self.section(title) + + max_key = max(len(k) for k in items) if items else 0 + for label, value in items.items(): + lbl = self._c(_GRAY, f" {label:<{max_key}}") + val = self._c(_WHITE, f" {value}") + print(f"{lbl}{val}") + + def progress(self, current: int, total: int, label: str = ""): + """Print a simple progress indicator. + + Args: + current: Current step number. + total: Total number of steps. + label: Optional label for the progress. + """ + pct = int(current / total * 100) if total > 0 else 0 + bar_width = 20 + filled = int(bar_width * current / total) if total > 0 else 0 + bar = "█" * filled + "░" * (bar_width - filled) + text = f" {self._c(_CYAN, bar)} {self._c(_GRAY, f'{pct:3d}%')}" + if label: + text += f" {self._c(_LIGHT_GRAY, label)}" + print(text) + + # ── Table display ───────────────────────────────────────────────── + + def table(self, headers: list[str], rows: list[list[str]], + max_col_width: int = 40): + """Print a formatted table with box-drawing characters. + + Args: + headers: Column header strings. + rows: List of rows, each a list of cell strings. + max_col_width: Maximum column width before truncation. + """ + if not headers: + return + + # Calculate column widths + col_widths = [min(len(h), max_col_width) for h in headers] + for row in rows: + for i, cell in enumerate(row): + if i < len(col_widths): + col_widths[i] = min( + max(col_widths[i], len(str(cell))), max_col_width + ) + + def pad(text: str, width: int) -> str: + t = str(text)[:width] + return t + " " * (width - len(t)) + + # Header + header_cells = [ + self._c(_CYAN + _BOLD, pad(h, col_widths[i])) + for i, h in enumerate(headers) + ] + sep = self._c(_DARK_GRAY, f" {_V_LINE} ") + header_line = f" {sep.join(header_cells)}" + print(header_line) + + # Separator + sep_parts = [self._c(_DARK_GRAY, _H_LINE * w) for w in col_widths] + sep_line = self._c(_DARK_GRAY, f" {'───'.join([_H_LINE * w for w in col_widths])}") + print(sep_line) + + # Rows + for row in rows: + cells = [] + for i, cell in enumerate(row): + if i < len(col_widths): + cells.append(self._c(_LIGHT_GRAY, pad(str(cell), col_widths[i]))) + row_sep = self._c(_DARK_GRAY, f" {_V_LINE} ") + print(f" {row_sep.join(cells)}") + + # ── Help display ────────────────────────────────────────────────── + + def help(self, commands: dict[str, str]): + """Print a formatted help listing. + + Args: + commands: Dict of command -> description pairs. + """ + self.section("Commands") + max_cmd = max(len(c) for c in commands) if commands else 0 + for cmd, desc in commands.items(): + cmd_styled = self._c(self.accent, f" {cmd:<{max_cmd}}") + desc_styled = self._c(_GRAY, f" {desc}") + print(f"{cmd_styled}{desc_styled}") + print() + + # ── Goodbye ─────────────────────────────────────────────────────── + + def print_goodbye(self): + """Print a styled goodbye message.""" + print(f"\n {_ICON_SMALL} {self._c(_GRAY, 'Goodbye!')}\n") + + # ── Prompt toolkit session factory ──────────────────────────────── + + def create_prompt_session(self): + """Create a prompt_toolkit PromptSession with skin styling. + + Returns: + A configured PromptSession, or None if prompt_toolkit unavailable. + """ + try: + from prompt_toolkit import PromptSession + from prompt_toolkit.history import FileHistory + from prompt_toolkit.auto_suggest import AutoSuggestFromHistory + from prompt_toolkit.formatted_text import FormattedText + + style = self.get_prompt_style() + + session = PromptSession( + history=FileHistory(self.history_file), + auto_suggest=AutoSuggestFromHistory(), + style=style, + enable_history_search=True, + ) + return session + except ImportError: + return None + + def get_input(self, pt_session, project_name: str = "", + modified: bool = False, context: str = "") -> str: + """Get input from user using prompt_toolkit or fallback. + + Args: + pt_session: A prompt_toolkit PromptSession (or None). + project_name: Current project name. + modified: Whether project has unsaved changes. + context: Optional context string. + + Returns: + User input string (stripped). + """ + if pt_session is not None: + from prompt_toolkit.formatted_text import FormattedText + tokens = self.prompt_tokens(project_name, modified, context) + return pt_session.prompt(FormattedText(tokens)).strip() + else: + raw_prompt = self.prompt(project_name, modified, context) + return input(raw_prompt).strip() + + # ── Toolbar builder ─────────────────────────────────────────────── + + def bottom_toolbar(self, items: dict[str, str]): + """Create a bottom toolbar callback for prompt_toolkit. + + Args: + items: Dict of label -> value pairs to show in toolbar. + + Returns: + A callable that returns FormattedText for the toolbar. + """ + def toolbar(): + from prompt_toolkit.formatted_text import FormattedText + parts = [] + for i, (k, v) in enumerate(items.items()): + if i > 0: + parts.append(("class:bottom-toolbar.text", " │ ")) + parts.append(("class:bottom-toolbar.text", f" {k}: ")) + parts.append(("class:bottom-toolbar", v)) + return FormattedText(parts) + return toolbar + + +# ── ANSI 256-color to hex mapping (for prompt_toolkit styles) ───────── + +_ANSI_256_TO_HEX = { + "\033[38;5;33m": "#0087ff", # audacity navy blue + "\033[38;5;35m": "#00af5f", # shotcut teal + "\033[38;5;39m": "#00afff", # inkscape bright blue + "\033[38;5;40m": "#00d700", # libreoffice green + "\033[38;5;55m": "#5f00af", # obs purple + "\033[38;5;69m": "#5f87ff", # kdenlive slate blue + "\033[38;5;75m": "#5fafff", # default sky blue + "\033[38;5;80m": "#5fd7d7", # brand cyan + "\033[38;5;208m": "#ff8700", # blender deep orange + "\033[38;5;214m": "#ffaf00", # gimp warm orange +} diff --git a/3MF/agent-harness/cli_anything/threemf/utils/threemf_backend.py b/3MF/agent-harness/cli_anything/threemf/utils/threemf_backend.py new file mode 100644 index 0000000..969a06c --- /dev/null +++ b/3MF/agent-harness/cli_anything/threemf/utils/threemf_backend.py @@ -0,0 +1,475 @@ +"""Geometry utilities -- trimesh / numpy integration for 3MF mesh analysis. + +All public functions are **pure**: they return new data structures and never +mutate their inputs. This aligns with the frozen dataclasses used in +:mod:`cli_anything.threemf.core.parser`. +""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +try: + import trimesh +except ImportError as _exc: # pragma: no cover + raise ImportError( + "trimesh is required for geometry utilities. " + "Install it with: pip install trimesh" + ) from _exc + +from cli_anything.threemf.core.parser import MeshData + + +# --------------------------------------------------------------------------- +# Conversion helpers +# --------------------------------------------------------------------------- + +def mesh_to_trimesh(mesh_data: MeshData) -> trimesh.Trimesh: + """Convert a :class:`MeshData` to a :class:`trimesh.Trimesh`. + + Parameters + ---------- + mesh_data: + Source mesh (frozen dataclass, never mutated). + + Returns + ------- + trimesh.Trimesh + A new Trimesh object. The input arrays are copied so that the + original ``MeshData`` is unaffected by any downstream mutations + performed by trimesh internals. + """ + return trimesh.Trimesh( + vertices=np.array(mesh_data.vertices, dtype=np.float64, copy=True), + faces=np.array(mesh_data.triangles, dtype=np.int32, copy=True), + process=False, + ) + + +def trimesh_to_mesh( + tm: trimesh.Trimesh, + object_id: str, + name: str, +) -> MeshData: + """Convert a :class:`trimesh.Trimesh` back to a :class:`MeshData`. + + Parameters + ---------- + tm: + Source trimesh object. + object_id: + The ```` attribute to assign. + name: + The ```` attribute to assign. + + Returns + ------- + MeshData + A new frozen dataclass. + """ + return MeshData( + object_id=object_id, + name=name, + vertices=np.array(tm.vertices, dtype=np.float64, copy=True), + triangles=np.array(tm.faces, dtype=np.int32, copy=True), + ) + + +# --------------------------------------------------------------------------- +# Mesh statistics +# --------------------------------------------------------------------------- + +def compute_mesh_stats(mesh_data: MeshData) -> dict[str, Any]: + """Compute summary statistics for a mesh. + + Returns + ------- + dict + Keys: + + - ``vertex_count`` (int) + - ``face_count`` (int) + - ``bounding_box`` -- dict with ``min`` and ``max`` (each a list + of three floats) plus ``size`` (list of three floats). + - ``watertight`` (bool) + - ``volume_mm3`` (float | None) -- ``None`` when mesh is not + watertight. + - ``surface_area_mm2`` (float) + """ + if mesh_data.vertices.shape[0] == 0 or mesh_data.triangles.shape[0] == 0: + return { + "vertex_count": int(mesh_data.vertices.shape[0]), + "face_count": int(mesh_data.triangles.shape[0]), + "bounding_box": {"min": [0.0, 0.0, 0.0], + "max": [0.0, 0.0, 0.0], + "size": [0.0, 0.0, 0.0]}, + "watertight": False, + "volume_mm3": None, + "surface_area_mm2": 0.0, + } + + tm = mesh_to_trimesh(mesh_data) + + bb_min = tm.bounds[0].tolist() + bb_max = tm.bounds[1].tolist() + bb_size = (tm.bounds[1] - tm.bounds[0]).tolist() + + watertight = bool(tm.is_watertight) + volume: float | None = float(tm.volume) if watertight else None + surface_area = float(tm.area) + + return { + "vertex_count": int(mesh_data.vertices.shape[0]), + "face_count": int(mesh_data.triangles.shape[0]), + "bounding_box": { + "min": bb_min, + "max": bb_max, + "size": bb_size, + }, + "watertight": watertight, + "volume_mm3": volume, + "surface_area_mm2": surface_area, + } + + +# --------------------------------------------------------------------------- +# Circle fitting (algebraic / Kasa method) +# --------------------------------------------------------------------------- + +def fit_circle_least_squares( + points_2d: np.ndarray, +) -> tuple[float, float, float, float]: + """Fit a circle to 2D points using the algebraic (Kasa) method. + + The Kasa method minimises the algebraic distance by solving the + over-determined system:: + + A @ [a, b, c]^T = d + + where ``x^2 + y^2 + a*x + b*y + c = 0`` describes the circle. + The centre is ``(-a/2, -b/2)`` and the radius is + ``sqrt(a^2/4 + b^2/4 - c)``. + + Parameters + ---------- + points_2d: + (N, 2) array of 2D coordinates. At least 3 points are required. + + Returns + ------- + tuple + ``(center_x, center_y, radius, fit_error)`` where *fit_error* is + the RMS deviation of point distances from the fitted circle. + + Raises + ------ + ValueError + If fewer than 3 points are provided. + """ + if points_2d.ndim != 2 or points_2d.shape[1] != 2: + raise ValueError( + f"points_2d must be (N, 2); got shape {points_2d.shape}" + ) + n = points_2d.shape[0] + if n < 3: + raise ValueError( + f"At least 3 points are required for circle fitting; got {n}" + ) + + x = points_2d[:, 0] + y = points_2d[:, 1] + + # Build the linear system: A @ [a, b, c]^T = d + # where x^2 + y^2 + a*x + b*y + c = 0 + A = np.column_stack([x, y, np.ones(n)]) + d = -(x ** 2 + y ** 2) + + # Least-squares solve + result, *_ = np.linalg.lstsq(A, d, rcond=None) + a, b, c = result + + cx = -a / 2.0 + cy = -b / 2.0 + r_sq = (a ** 2 + b ** 2) / 4.0 - c + if r_sq < 0: + # Degenerate case -- points are collinear or nearly so + radius = 0.0 + fit_error = float("inf") + return (cx, cy, radius, fit_error) + + radius = float(np.sqrt(r_sq)) + + # RMS fit error + distances = np.sqrt((x - cx) ** 2 + (y - cy) ** 2) + fit_error = float(np.sqrt(np.mean((distances - radius) ** 2))) + + return (cx, cy, radius, fit_error) + + +# --------------------------------------------------------------------------- +# Cross-section analysis +# --------------------------------------------------------------------------- + +# Cross-section planes are inset from the mesh bounds by this fraction of the +# axis span, to avoid degenerate slices exactly at the faces. The inspector +# reuses it to recover a through hole's rim vertices, which sit up to one inset +# beyond the outermost sampled plane. +PLANE_INSET_FRACTION = 0.02 + + +def _axis_plane_normal(axis: int) -> np.ndarray: + """Return a unit normal vector for the given axis index (0=X, 1=Y, 2=Z).""" + normal = np.zeros(3, dtype=np.float64) + normal[axis] = 1.0 + return normal + + +def _project_to_2d( + points_3d: np.ndarray, + axis: int, +) -> np.ndarray: + """Project 3D points onto the 2D plane perpendicular to *axis*. + + For axis=0 (X), returns columns (Y, Z). + For axis=1 (Y), returns columns (X, Z). + For axis=2 (Z), returns columns (X, Y). + """ + other_axes = [i for i in range(3) if i != axis] + return points_3d[:, other_axes] + + +def cross_section_circles( + mesh_data: MeshData, + axis: int = 0, + num_planes: int = 20, +) -> list[dict[str, Any]]: + """Take cross-sections and detect circular entities. + + The mesh is sliced at *num_planes* evenly-spaced planes perpendicular + to the given axis. For each resulting contour, a circle is fitted using + :func:`fit_circle_least_squares`. Only results with a reasonable fit + error (< 5 % of radius) are included. + + Parameters + ---------- + mesh_data: + Source mesh. + axis: + Axis perpendicular to the slicing planes (0=X, 1=Y, 2=Z). + Default ``0`` (X-axis) since most 3D-printed holes run along X. + num_planes: + Number of evenly-spaced slicing planes. + + Returns + ------- + list[dict] + Each dict contains: + + - ``center`` -- (2,) list of centre coordinates in the 2D plane. + - ``radius`` -- fitted radius. + - ``diameter`` -- 2 * radius. + - ``plane_value`` -- coordinate along the slicing axis. + - ``num_points`` -- number of contour points used for fitting. + - ``fit_error`` -- RMS distance error of the fit. + """ + if mesh_data.vertices.shape[0] == 0 or mesh_data.triangles.shape[0] == 0: + return [] + + if axis not in (0, 1, 2): + raise ValueError(f"axis must be 0, 1 or 2; got {axis}") + + if num_planes < 1: + raise ValueError(f"num_planes must be >= 1; got {num_planes}") + + tm = mesh_to_trimesh(mesh_data) + normal = _axis_plane_normal(axis) + + # Determine slicing range along the chosen axis + axis_min = float(tm.bounds[0][axis]) + axis_max = float(tm.bounds[1][axis]) + span = axis_max - axis_min + + if span < 1e-12: + return [] + + # Inset slightly to avoid degenerate boundary slices + margin = span * PLANE_INSET_FRACTION + plane_values = np.linspace(axis_min + margin, axis_max - margin, num_planes) + + results: list[dict[str, Any]] = [] + + for pv in plane_values: + origin = np.zeros(3, dtype=np.float64) + origin[axis] = pv + + try: + section = tm.section(plane_origin=origin, plane_normal=normal) + except Exception: + continue + + if section is None: + continue + + # section is a Path3D; extract discrete points from each entity + try: + discrete = section.discrete + except Exception: + continue + + for path_points in discrete: + pts_3d = np.asarray(path_points, dtype=np.float64) + if pts_3d.ndim != 2 or pts_3d.shape[0] < 5: + # Need a reasonable number of points for a good circle fit + continue + + pts_2d = _project_to_2d(pts_3d, axis) + + try: + cx, cy, radius, fit_error = fit_circle_least_squares(pts_2d) + except ValueError: + continue + + if radius < 1e-9: + continue + + relative_error = fit_error / radius + if relative_error > 0.05: + # Not circular enough -- skip + continue + + results.append({ + "center": [float(cx), float(cy)], + "radius": float(radius), + "diameter": float(radius * 2.0), + "plane_value": float(pv), + "num_points": int(pts_2d.shape[0]), + "fit_error": float(fit_error), + }) + + return results + + +# --------------------------------------------------------------------------- +# Cylinder detection and vertex manipulation +# --------------------------------------------------------------------------- + +def find_cylinder_vertices( + vertices: np.ndarray, + center_yz: np.ndarray, + axis: int, + radius: float, + tolerance: float, +) -> np.ndarray: + """Find vertex indices that lie on a cylindrical surface. + + A vertex is considered to be on the cylinder when its distance from the + cylinder axis (measured in the 2D plane perpendicular to *axis*) is + within *tolerance* of *radius*. + + Parameters + ---------- + vertices: + (N, 3) array of vertex positions. + center_yz: + (2,) array -- centre of the cylinder in the plane perpendicular to + *axis*. For axis=0 this is (center_y, center_z); for axis=1 it is + (center_x, center_z); for axis=2 it is (center_x, center_y). + axis: + Cylinder axis direction (0=X, 1=Y, 2=Z). + radius: + Nominal radius of the cylinder. + tolerance: + Maximum allowed deviation from *radius*. + + Returns + ------- + np.ndarray + 1-D int array of matching vertex indices. + """ + if vertices.shape[0] == 0: + return np.array([], dtype=np.intp) + + if axis not in (0, 1, 2): + raise ValueError(f"axis must be 0, 1 or 2; got {axis}") + + center_yz = np.asarray(center_yz, dtype=np.float64).ravel() + if center_yz.shape[0] != 2: + raise ValueError( + f"center_yz must have 2 elements; got {center_yz.shape[0]}" + ) + + other_axes = [i for i in range(3) if i != axis] + pts_2d = vertices[:, other_axes] + + distances = np.sqrt( + (pts_2d[:, 0] - center_yz[0]) ** 2 + + (pts_2d[:, 1] - center_yz[1]) ** 2 + ) + + mask = np.abs(distances - radius) <= tolerance + return np.nonzero(mask)[0].astype(np.intp) + + +def scale_vertices_radially( + vertices: np.ndarray, + indices: np.ndarray, + center: np.ndarray, + axis: int, + new_radius: float, +) -> np.ndarray: + """Scale selected vertices to a new radius, preserving angular position. + + Only the two coordinates perpendicular to *axis* are modified; the + coordinate along *axis* is left unchanged. + + Parameters + ---------- + vertices: + (N, 3) vertex array. **Never mutated** -- a copy is returned. + indices: + 1-D array of vertex indices to scale. + center: + (2,) centre of the cylinder in the perpendicular plane. + axis: + Cylinder axis (0=X, 1=Y, 2=Z). + new_radius: + Target radius. + + Returns + ------- + np.ndarray + A **new** (N, 3) array with the selected vertices moved to + *new_radius*. + """ + if axis not in (0, 1, 2): + raise ValueError(f"axis must be 0, 1 or 2; got {axis}") + + center = np.asarray(center, dtype=np.float64).ravel() + if center.shape[0] != 2: + raise ValueError(f"center must have 2 elements; got {center.shape[0]}") + + # Always work on a copy + result = np.array(vertices, dtype=np.float64, copy=True) + + if indices.shape[0] == 0: + return result + + other_axes = [i for i in range(3) if i != axis] + ax_u, ax_v = other_axes + + # Extract the 2D positions of selected vertices relative to centre + du = result[indices, ax_u] - center[0] + dv = result[indices, ax_v] - center[1] + current_radius = np.sqrt(du ** 2 + dv ** 2) + + # Avoid division by zero for vertices exactly at the centre + safe_mask = current_radius > 1e-12 + scale = np.ones_like(current_radius) + scale[safe_mask] = new_radius / current_radius[safe_mask] + + result[indices, ax_u] = center[0] + du * scale + result[indices, ax_v] = center[1] + dv * scale + + return result diff --git a/3MF/agent-harness/setup.py b/3MF/agent-harness/setup.py new file mode 100644 index 0000000..e4df38c --- /dev/null +++ b/3MF/agent-harness/setup.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +""" +setup.py for cli-anything-3mf + +Install with: pip install -e . +Or publish to PyPI: python -m build && twine upload dist/* +""" + +from setuptools import setup, find_namespace_packages + +with open("cli_anything/threemf/README.md", "r", encoding="utf-8") as fh: + long_description = fh.read() + +setup( + name="cli-anything-3mf", + version="1.0.0", + author="cli-anything contributors", + author_email="", + description="CLI harness for 3MF — Detect and resize cylindrical holes, repair meshes, compare 3D printing files", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/HKUDS/CLI-Anything", + packages=find_namespace_packages(include=["cli_anything.*"]), + classifiers=[ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Topic :: Scientific/Engineering", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + ], + python_requires=">=3.9", + install_requires=[ + "click>=8.0.0", + "prompt-toolkit>=3.0.0", + "numpy>=1.24.0", + "scipy>=1.10.0", + "trimesh>=4.0.0", + ], + extras_require={ + "dev": [ + "pytest>=7.0.0", + "pytest-cov>=4.0.0", + ], + }, + entry_points={ + "console_scripts": [ + "cli-anything-3mf=cli_anything.threemf.threemf_cli:main", + ], + }, + package_data={ + "cli_anything.threemf": ["skills/*.md"], + }, + include_package_data=True, + zip_safe=False, +) diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000..c907e7e --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,28 @@ +cff-version: 1.2.0 +message: "If you find CLI-Anything useful, please cite our technical report." +title: "CLI-Anything" +authors: + - family-names: Yang + given-names: Yuhao + - family-names: Fan + given-names: Tianyu + - family-names: Huang + given-names: Chao +repository-code: "https://github.com/HKUDS/CLI-Anything" +url: "https://arxiv.org/abs/2606.03854" +preferred-citation: + type: article + title: "CLI-Anything: Towards Agent-Native Computer Use" + authors: + - family-names: Yang + given-names: Yuhao + - family-names: Fan + given-names: Tianyu + - family-names: Huang + given-names: Chao + year: 2026 + url: "https://arxiv.org/abs/2606.03854" + identifiers: + - type: other + value: "arXiv:2606.03854" + description: "arXiv identifier" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a485a89 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,188 @@ +# Contributing to CLI-Anything + +Thank you for your interest in contributing to CLI-Anything! This guide will help you get started. + +## Types of Contributions + +We welcome three main categories of contributions: + +### A) CLIs for New Software + +Adding a new CLI harness is the most impactful contribution. You can either add the harness **inside this monorepo** or host it in your own **standalone repository** — both are first-class citizens on the CLI-Hub. + +#### Option 1: In-repo harness + +Place your code under `/agent-harness/` and ensure the following: + +1. **`.md`** — the SOP document exists at `/agent-harness/.md` describing the harness architecture. +2. **`SKILL.md`** — the canonical AI-discoverable skill definition exists at `skills/cli-anything-/SKILL.md`, and the packaged compatibility copy exists at `cli_anything//skills/SKILL.md`. +3. **Tests** — unit tests (`test_core.py`, passable without backend) and E2E tests (`test_full_e2e.py`) are present and passing. +4. **`README.md`** — the project README includes the new software with a link to its harness directory. +5. **`registry.json`** — add an entry for the new software (see [Registry fields](#registry-fields) below). +6. **`repl_skin.py`** — an unmodified copy from the plugin exists in `utils/`. + +#### Option 2: Standalone repository (external) + +Host your CLI in your own repo and submit a **registry-only PR** to this repo. Your PR only needs to add an entry to `registry.json` — no code in this monorepo is required. This is ideal if you want full control over releases, CI, and versioning. + +Requirements for standalone CLIs: + +1. **Published package** — your CLI must be installable via `pip install ` (PyPI) or a `pip install git+https://...` URL. +2. **`SKILL.md`** — an AI-discoverable skill definition exists somewhere in your repo. +3. **Tests** — your repo should have its own test suite. +4. **`registry.json`** — add an entry with `source_url` pointing to your repo and `skill_md` pointing to the raw URL of your SKILL.md (see [Registry fields](#registry-fields) below). + +### B) New Features + +Feature contributions improve existing harnesses or the plugin framework. Examples include new CLI commands, output formats, backend improvements, or cross-platform fixes. + +- Open an issue first to discuss the feature before starting work. +- Follow existing code patterns and conventions in the target harness. +- Include tests for any new functionality. + +### C) Bug Fixes + +Bug fixes resolve incorrect behavior in existing harnesses or the plugin. + +- Reference the related issue in your PR (e.g., `Fixes #123`). +- Include a test that reproduces the bug and verifies the fix. +- Ensure all existing tests for the affected harness still pass. + +## CLI-Hub & Registry + +All available CLIs are listed in `registry.json` at the repo root and displayed on the [CLI-Hub](https://hkuds.github.io/CLI-Anything/hub/). The hub reads `registry.json` directly from `main`, so it updates immediately when a PR is merged. + +### Registry fields + +Include an entry in `registry.json` as part of your PR. Each field is described below: + +| Field | Required | Description | +|-------|----------|-------------| +| `name` | Yes | Lowercase identifier (e.g. `"my-software"`). Must be unique. | +| `display_name` | Yes | Human-readable name shown on the hub (e.g. `"My Software"`). | +| `version` | Yes | Semantic version string (e.g. `"1.0.0"`). | +| `description` | Yes | One-line description of what the CLI does. | +| `requires` | Yes | Runtime dependencies the user needs (e.g. `"Docker"`) or `null`. | +| `homepage` | Yes | Official homepage of the **target software** (not your repo). | +| `source_url` | Yes | For standalone repos: URL to your repo (e.g. `"https://github.com/user/repo"`). For in-repo harnesses: `null` (the hub auto-links to `/agent-harness/`). | +| `install_cmd` | Yes | Full pip install command. PyPI: `"pip install cli-anything-my-software"`. In-repo: `"pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=my-software/agent-harness"`. | +| `entry_point` | Yes | CLI command name (e.g. `"cli-anything-my-software"`). | +| `skill_md` | Yes | Path to canonical SKILL.md. For standalone repos: full URL (e.g. `"https://github.com/user/repo/blob/main/.../SKILL.md"`). For in-repo: relative path under the repo-root `skills/` tree (e.g. `"skills/cli-anything-my-software/SKILL.md"`). Set to `null` if not yet available. | +| `category` | Yes | One of the existing categories (check `registry.json` for examples). | +| `contributors` | Yes | Array of `{"name": "...", "url": "..."}` objects listing all contributors. | + +**In-repo example:** + +```json +{ + "name": "my-software", + "display_name": "My Software", + "version": "1.0.0", + "description": "Short description of what the CLI does", + "requires": "backend software or null", + "homepage": "https://my-software.org", + "source_url": null, + "install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=my-software/agent-harness", + "entry_point": "cli-anything-my-software", + "skill_md": "skills/cli-anything-my-software/SKILL.md", + "category": "category-name", + "contributors": [ + {"name": "your-github-username", "url": "https://github.com/your-github-username"} + ] +} +``` + +**Standalone repo example (multiple contributors):** + +```json +{ + "name": "my-software", + "display_name": "My Software", + "version": "2.0.0", + "description": "Short description of what the CLI does", + "requires": "backend software or null", + "homepage": "https://my-software.org", + "source_url": "https://github.com/your-username/cli-anything-my-software", + "install_cmd": "pip install cli-anything-my-software", + "entry_point": "cli-anything-my-software", + "skill_md": "https://github.com/your-username/cli-anything-my-software/blob/main/cli_anything/my_software/skills/SKILL.md", + "category": "category-name", + "contributors": [ + {"name": "original-author", "url": "https://github.com/original-author"}, + {"name": "current-maintainer", "url": "https://github.com/current-maintainer"} + ] +} +``` + +### Updating an existing CLI on the Hub + +When you modify an existing harness, update its `registry.json` entry in the same PR: + +- Bump the `version` field to reflect the change. +- Update `description`, `requires`, or `category` if they changed. +- The hub will reflect the update as soon as the PR is merged to `main`. + +## Development Setup + +Each generated CLI lives in `/agent-harness/` and is an independent Python package: + +```bash +# Clone the repo +git clone https://github.com/HKUDS/CLI-Anything.git +cd CLI-Anything + +# Install a harness in editable mode +cd /agent-harness +pip install -e . + +# Run tests +python3 -m pytest cli_anything//tests/ -v +``` + +### Requirements + +- Python 3.10+ +- Click 8.0+ +- pytest 7.0+ + +## Code Style + +- Follow PEP 8 conventions. +- Use type hints where practical. +- All CLI commands must support the `--json` flag for machine-readable output. + +## Commit Messages + +Use clear, descriptive commit messages following the conventional format: + +``` +feat: add Krita CLI harness +fix: resolve Blender backend path on macOS +docs: update README with new software entry +test: add unit tests for Inkscape layer commands +``` + +## Running Tests + +```bash +# Unit tests (no backend software needed) +python3 -m pytest cli_anything//tests/test_core.py -v + +# E2E tests (requires real backend installed) +python3 -m pytest cli_anything//tests/test_full_e2e.py -v + +# All tests for a harness +python3 -m pytest cli_anything//tests/ -v +``` + +## Submitting a Pull Request + +1. Fork the repository and create a feature branch from `main`. +2. Make your changes following the guidelines above. +3. Ensure all tests pass for any harnesses you modified. +4. Push your branch and open a Pull Request against `main`. +5. Fill out the PR template completely. + +## Questions? + +If you have questions, feel free to open a [Discussion](https://github.com/HKUDS/CLI-Anything/discussions) or an issue tagged with `type: question`. diff --git a/QGIS/agent-harness/QGIS.md b/QGIS/agent-harness/QGIS.md new file mode 100644 index 0000000..8d1c588 --- /dev/null +++ b/QGIS/agent-harness/QGIS.md @@ -0,0 +1,127 @@ +# QGIS harness architecture notes + +This harness targets the real QGIS runtime already present on the machine and follows the cli-anything harness model: keep authoring state inside a long-lived Python process, and use existing backend CLIs where QGIS already exposes them cleanly. + +## Backend split + +### PyQGIS for stateful authoring + +Use PyQGIS for operations that mutate or inspect in-memory project state: + +- project create/open/save +- project CRS/title updates +- writable vector layer creation +- feature insertion +- layout creation and item authoring +- project/layer/layout summaries for CLI output + +Key APIs and source references: + +- `QgsApplication` + - `QGIS/src/core/qgsapplication.h` + - `QGIS/src/core/qgsapplication.cpp` +- `QgsProject` + - `QGIS/src/core/project/qgsproject.h` + - `QGIS/src/core/project/qgsproject.cpp` +- `QgsPrintLayout` / layout items + - runtime API surfaced via PyQGIS +- `QgsLayoutExporter` + - `QGIS/src/core/layout/qgslayoutexporter.h` + +### `qgis_process --json` for processing and export + +Use `qgis_process --json` for operations that already exist as stable QGIS processing algorithms: + +- algorithm discovery (`list`) +- algorithm help (`help `) +- generic algorithm execution (`run `) +- layout PDF export (`native:printlayouttopdf`) +- layout image export (`native:printlayouttoimage`) + +Key references: + +- process CLI entrypoint + - `QGIS/src/process/main.cpp` + - `QGIS/src/process/qgsprocess.cpp` + - `QGIS/src/process/qgsprocess.h` +- layout export algorithms + - `QGIS/src/analysis/processing/qgsalgorithmlayouttopdf.cpp` + - `QGIS/src/analysis/processing/qgsalgorithmlayouttoimage.cpp` +- exporter tests/examples + - `QGIS/tests/src/python/test_qgslayoutexporter.py` + +## Why this split + +PyQGIS is the right layer for commands that need live project state across multiple commands in a REPL. `qgis_process` is the right layer for processing algorithms because QGIS already ships a supported CLI contract, including JSON output and algorithm metadata. + +This keeps the harness close to QGIS instead of reimplementing backend logic in Python. + +## How the harness was surveyed + +This harness was not generated automatically from `QGIS/src`. The command groups were chosen by surveying QGIS' stable runtime surfaces and then wrapping a narrow, testable subset. + +- `project`, `layer`, `feature`, and `layout` wrap PyQGIS authoring surfaces that need live project state +- `export` and `process` wrap stable `qgis_process --json` surfaces instead of reimplementing algorithms in Python +- `session` is harness-local ergonomics for REPL/history tracking, not a native QGIS feature + +This also explains the current boundaries: + +- the harness intentionally exposes a small layout surface instead of the full desktop layout system +- the harness can run processing algorithms directly against shapefiles and other datasource paths +- the harness does not currently provide a first-class command to add an arbitrary existing shapefile into a project + +## Data model choices + +### Projects + +Projects are saved immediately to `.qgz` or `.qgs` paths. The harness normalizes bare output names to `.qgz` by default. + +### Writable layers + +New vector layers are created as project-side GeoPackage layers instead of ephemeral memory layers. The default datastore is derived from the project path: + +- `.qgz` -> `_data.gpkg` + +This keeps authored data on disk and makes subsequent processing/export commands work against real datasets. + +### Features + +Feature insertion accepts: + +- WKT geometry +- repeatable `--attr key=value` + +Attributes are coerced against the declared QGIS field types so CLI input remains simple while the stored layer schema stays authoritative. + +### Layouts + +The harness only implements a narrow but useful layout surface in v1: + +- create/remove/list layouts +- add map items +- add label items +- derive map extent from current project layers when no explicit extent is given + +## Session model + +The CLI maintains lightweight session state for: + +- current project path +- modified flag reporting +- command history + +The REPL is the default mode when no subcommand is passed. One-shot commands can still use `--project` to bind a command to a saved project path. + +## Output model + +Every command supports a stable JSON shape through `--json`. Human-readable output is intentionally secondary; agent-facing usage should prefer JSON. + +## Testing strategy + +The test suite should cover three layers: + +1. direct module tests against PyQGIS helpers +2. real E2E flows against the actual QGIS runtime +3. subprocess tests against the installed `cli-anything-qgis` executable + +That combination checks both the library layer and the packaging/runtime contract. diff --git a/QGIS/agent-harness/cli_anything/qgis/README.md b/QGIS/agent-harness/cli_anything/qgis/README.md new file mode 100644 index 0000000..20ad7db --- /dev/null +++ b/QGIS/agent-harness/cli_anything/qgis/README.md @@ -0,0 +1,183 @@ +# cli-anything-qgis + +A stateful CLI harness for QGIS that uses the real QGIS runtime. + +## What it does + +- manages `.qgs` / `.qgz` projects with PyQGIS +- creates writable GeoPackage-backed vector layers +- adds and inspects features with WKT geometry +- authors simple print layouts +- exports layouts through `qgis_process --json` +- exposes generic QGIS processing discovery and execution +- supports machine-readable `--json` output for every command +- starts a stateful REPL when run without a subcommand + +## Runtime model + +This harness deliberately splits responsibilities: + +- **PyQGIS** handles project, layer, feature, and layout authoring +- **`qgis_process --json`** handles generic processing and layout export algorithms + +That keeps authoring stateful while still using QGIS' existing processing CLI for backend execution. + +## Prerequisites + +- QGIS installed with `qgis_process` on `PATH` +- PyQGIS importable from the same Python used to run this package +- Python 3.10+ + +Quick checks: + +```bash +qgis_process --version +python3 -c "from qgis.core import QgsApplication; print('pyqgis-ok')" +``` + +## Installation + +```bash +cd QGIS/agent-harness +python3 -m pip install -e . +``` + +If PyQGIS comes from your system packages, a plain virtual environment may not see the `qgis` Python module. In that case, create the environment with system site packages enabled before installing: + +```bash +python3 -m venv --system-site-packages .venv +. .venv/bin/activate +python3 -m pip install -e . +``` + +Verify: + +```bash +which cli-anything-qgis +cli-anything-qgis --help +cli-anything-qgis --json process help native:printlayouttopdf +``` + +## Agent guidance + +Prefer `--json` unless a human-readable summary is specifically more useful. + +## More docs + +- architecture note: [`../../QGIS.md`](../../QGIS.md) +- Chinese walkthrough and real-data tutorial: [`../../TUTORIAL.md`](../../TUTORIAL.md) + +## Usage + +### One-shot commands + +```bash +# Create a project +cli-anything-qgis --json project new -o demo.qgz --title "Demo" --crs EPSG:4326 + +# Create a writable layer in the project's sidecar GeoPackage +cli-anything-qgis --json --project demo.qgz layer create-vector \ + --name places \ + --geometry point \ + --field name:string \ + --field score:int + +# Add features by WKT +cli-anything-qgis --json --project demo.qgz feature add \ + --layer places \ + --wkt "POINT(1 2)" \ + --attr name=HQ \ + --attr score=5 + +# Create a layout and add items +cli-anything-qgis --json --project demo.qgz layout create --name Main +# Auto extent should work even for point-only projects; pass --extent only when you want explicit framing. +cli-anything-qgis --json --project demo.qgz layout add-map --layout Main --x 10 --y 20 --width 180 --height 120 +cli-anything-qgis --json --project demo.qgz layout add-label --layout Main --text "Demo map" --x 10 --y 8 --width 120 --height 10 + +# Export through the real QGIS backend +cli-anything-qgis --json --project demo.qgz export pdf output.pdf --layout Main --overwrite +cli-anything-qgis --json --project demo.qgz export image output.png --layout Main --overwrite + +# Inspect processing algorithms +cli-anything-qgis --json process list +cli-anything-qgis --json process help native:buffer +cli-anything-qgis --json --project demo.qgz process run native:buffer \ + --param INPUT=places \ + --param DISTANCE=10 \ + --param SEGMENTS=8 \ + --param END_CAP_STYLE=0 \ + --param JOIN_STYLE=0 \ + --param MITER_LIMIT=2 \ + --param DISSOLVE=false \ + --param OUTPUT=/tmp/buffer.gpkg +``` + +### REPL + +Run without arguments: + +```bash +cli-anything-qgis +``` + +Example session: + +```text +project new -o demo.qgz --title "Demo" +layer create-vector --name places --geometry point --field name:string +feature add --layer places --wkt "POINT(1 2)" --attr name=HQ +layout create --name Main +layout add-map --layout Main --x 10 --y 20 --width 180 --height 120 +export pdf demo.pdf --layout Main --overwrite +session status +quit +``` + +## Command groups + +### `project` +- `new` — create a new project and save it immediately +- `open` — open an existing project +- `save` — save the active project +- `info` — inspect the current project +- `set-crs` — change project CRS + +### `layer` +- `create-vector` — create a GeoPackage-backed vector layer +- `list` — list project layers +- `info` — inspect one layer +- `remove` — remove a layer from the project + +### `feature` +- `add` — add a feature with WKT geometry and `key=value` attrs +- `list` — inspect features from a layer + +### `layout` +- `create` — create a print layout +- `list` — list layouts +- `info` — inspect one layout +- `remove` — remove a layout +- `add-map` — add a map item +- `add-label` — add a text label item + +### `export` +- `presets` — list supported export modes +- `pdf` — export a layout as PDF +- `image` — export a layout as an image + +### `process` +- `list` — list installed processing algorithms +- `help` — inspect algorithm parameters and outputs +- `run` — execute a processing algorithm with repeatable `--param KEY=VALUE` + +### `session` +- `status` — inspect current session state +- `history` — inspect recent command history + +## Testing + +```bash +python3 -m pytest cli_anything/qgis/tests/test_core.py -v +python3 -m pytest cli_anything/qgis/tests/test_full_e2e.py -v -s +``` diff --git a/QGIS/agent-harness/cli_anything/qgis/__init__.py b/QGIS/agent-harness/cli_anything/qgis/__init__.py new file mode 100644 index 0000000..9bb330b --- /dev/null +++ b/QGIS/agent-harness/cli_anything/qgis/__init__.py @@ -0,0 +1,3 @@ +"""cli-anything-qgis package.""" + +__version__ = "1.0.0" diff --git a/QGIS/agent-harness/cli_anything/qgis/__main__.py b/QGIS/agent-harness/cli_anything/qgis/__main__.py new file mode 100644 index 0000000..10513b3 --- /dev/null +++ b/QGIS/agent-harness/cli_anything/qgis/__main__.py @@ -0,0 +1,7 @@ +"""Run cli-anything-qgis as a module.""" + +from cli_anything.qgis.qgis_cli import main + + +if __name__ == "__main__": + main() diff --git a/QGIS/agent-harness/cli_anything/qgis/core/__init__.py b/QGIS/agent-harness/cli_anything/qgis/core/__init__.py new file mode 100644 index 0000000..81a0804 --- /dev/null +++ b/QGIS/agent-harness/cli_anything/qgis/core/__init__.py @@ -0,0 +1 @@ +"""Core domain modules for cli-anything-qgis.""" diff --git a/QGIS/agent-harness/cli_anything/qgis/core/export.py b/QGIS/agent-harness/cli_anything/qgis/core/export.py new file mode 100644 index 0000000..3191301 --- /dev/null +++ b/QGIS/agent-harness/cli_anything/qgis/core/export.py @@ -0,0 +1,140 @@ +"""Layout export helpers for cli-anything-qgis.""" + +from __future__ import annotations + +import os +from pathlib import Path + +from cli_anything.qgis.core import project as project_mod +from cli_anything.qgis.core.layouts import get_layout +from cli_anything.qgis.utils import qgis_backend as backend +from cli_anything.qgis.utils.qgis_backend import QgisBackendError + + +def _normalize_output_path(path: str) -> str: + return str(Path(path).expanduser().resolve()) + + +def _bool_param(value: bool) -> str: + return "true" if value else "false" + + +def _prepare_output(path: str, overwrite: bool) -> str: + output_path = _normalize_output_path(path) + target = Path(output_path) + target.parent.mkdir(parents=True, exist_ok=True) + if target.exists(): + if not overwrite: + raise QgisBackendError( + f"Output already exists: {output_path}. Use --overwrite to replace it." + ) + target.unlink() + return output_path + + +def export_presets() -> dict: + """Describe the supported layout export modes.""" + return { + "formats": [ + { + "name": "pdf", + "algorithm": "native:printlayouttopdf", + "description": "Export a named print layout as a PDF file.", + }, + { + "name": "image", + "algorithm": "native:printlayouttoimage", + "description": "Export a named print layout as an image file such as PNG.", + }, + ] + } + + +def export_layout_pdf( + output_path: str, + *, + layout_name: str, + dpi: float | None = None, + force_vector: bool = False, + force_raster: bool = False, + georeference: bool = True, + overwrite: bool = False, +) -> dict: + """Export a print layout to PDF via qgis_process.""" + get_layout(layout_name) + project_info = project_mod.save_if_dirty() + project_path = project_mod.require_saved_project_path() + output = _prepare_output(output_path, overwrite) + + parameters = [ + f"LAYOUT={layout_name}", + f"OUTPUT={output}", + f"FORCE_VECTOR={_bool_param(force_vector)}", + f"FORCE_RASTER={_bool_param(force_raster)}", + f"GEOREFERENCE={_bool_param(georeference)}", + ] + if dpi is not None: + parameters.append(f"DPI={dpi}") + + payload = backend.run_algorithm( + "native:printlayouttopdf", + parameters=parameters, + project_path=project_path, + ) + + if not os.path.exists(output): + raise QgisBackendError( + f"Export succeeded but output file was not created: {output}" + ) + + return { + "format": "pdf", + "layout": layout_name, + "output": payload.get("results", {}).get("OUTPUT", output), + "file_size": os.path.getsize(output), + "project": project_info, + "results": payload.get("results", {}), + "log": payload.get("log", []), + } + + +def export_layout_image( + output_path: str, + *, + layout_name: str, + dpi: float | None = None, + overwrite: bool = False, +) -> dict: + """Export a print layout to an image via qgis_process.""" + get_layout(layout_name) + project_info = project_mod.save_if_dirty() + project_path = project_mod.require_saved_project_path() + output = _prepare_output(output_path, overwrite) + + parameters = [ + f"LAYOUT={layout_name}", + f"OUTPUT={output}", + ] + if dpi is not None: + parameters.append(f"DPI={dpi}") + + payload = backend.run_algorithm( + "native:printlayouttoimage", + parameters=parameters, + project_path=project_path, + ) + + if not os.path.exists(output): + raise QgisBackendError( + f"Export succeeded but output file was not created: {output}" + ) + + return { + "format": "image", + "layout": layout_name, + "output": payload.get("results", {}).get("OUTPUT", output), + "file_size": os.path.getsize(output), + "project": project_info, + "results": payload.get("results", {}), + "log": payload.get("log", []), + } diff --git a/QGIS/agent-harness/cli_anything/qgis/core/features.py b/QGIS/agent-harness/cli_anything/qgis/core/features.py new file mode 100644 index 0000000..da77863 --- /dev/null +++ b/QGIS/agent-harness/cli_anything/qgis/core/features.py @@ -0,0 +1,109 @@ +"""Feature editing helpers for cli-anything-qgis.""" + +from __future__ import annotations + +from cli_anything.qgis.core.layers import get_layer, layer_summary +from cli_anything.qgis.utils.qgis_backend import QgisBackendError, ensure_qgis_app + + +def _ensure_vector_layer(layer) -> None: + from qgis.core import QgsMapLayerType + + if layer.type() != QgsMapLayerType.VectorLayer: + raise QgisBackendError("This command only supports vector layers") + + +def _coerce_value(raw_value: str, field) -> object: + from qgis.PyQt.QtCore import QMetaType + + meta_type = field.type() + value = raw_value.strip() + + if meta_type == QMetaType.Type.Int: + return int(value) + if meta_type == QMetaType.Type.Double: + return float(value) + if meta_type == QMetaType.Type.Bool: + lowered = value.lower() + if lowered in {"true", "1", "yes", "on"}: + return True + if lowered in {"false", "0", "no", "off"}: + return False + raise QgisBackendError(f"Invalid boolean value: {raw_value}") + return value + + +def _attribute_map(feature, layer) -> dict: + result = {} + for field in layer.fields(): + result[field.name()] = feature[field.name()] + return result + + +def _feature_summary(feature, layer) -> dict: + geometry = feature.geometry() + return { + "id": int(feature.id()), + "geometry_wkt": geometry.asWkt() if geometry and not geometry.isNull() else None, + "attributes": _attribute_map(feature, layer), + } + + +def add_feature(layer_identifier: str, wkt: str, attr_specs: list[str]) -> dict: + """Add a feature to a vector layer using WKT and key=value attributes.""" + ensure_qgis_app() + from qgis.core import QgsFeature, QgsGeometry + + layer = get_layer(layer_identifier) + _ensure_vector_layer(layer) + + geometry = QgsGeometry.fromWkt(wkt) + if geometry.isNull(): + raise QgisBackendError(f"Invalid WKT geometry: {wkt}") + + provided_attrs: dict[str, object] = {} + for spec in attr_specs: + key, separator, raw_value = spec.partition("=") + if not separator or not key.strip(): + raise QgisBackendError( + f"Invalid attribute specification: {spec}. Use key=value." + ) + field_index = layer.fields().indexFromName(key.strip()) + if field_index < 0: + raise QgisBackendError(f"Unknown field: {key.strip()}") + field = layer.fields()[field_index] + provided_attrs[key.strip()] = _coerce_value(raw_value, field) + + feature = QgsFeature(layer.fields()) + feature.setGeometry(geometry) + for field in layer.fields(): + feature[field.name()] = provided_attrs.get(field.name()) + + added, features = layer.dataProvider().addFeatures([feature]) + if not added: + raise QgisBackendError(f"Failed to add feature to layer: {layer.name()}") + + layer.updateExtents() + added_feature = features[0] if features else feature + return { + "layer": layer_summary(layer), + "feature": _feature_summary(added_feature, layer), + } + + +def list_features(layer_identifier: str, limit: int = 20) -> dict: + """List features from a vector layer.""" + layer = get_layer(layer_identifier) + _ensure_vector_layer(layer) + + features = [] + for index, feature in enumerate(layer.getFeatures()): + if limit and index >= limit: + break + features.append(_feature_summary(feature, layer)) + + return { + "layer": layer_summary(layer), + "feature_count": int(layer.featureCount()), + "features": features, + } diff --git a/QGIS/agent-harness/cli_anything/qgis/core/layers.py b/QGIS/agent-harness/cli_anything/qgis/core/layers.py new file mode 100644 index 0000000..77a4ff9 --- /dev/null +++ b/QGIS/agent-harness/cli_anything/qgis/core/layers.py @@ -0,0 +1,232 @@ +"""Layer lifecycle helpers for cli-anything-qgis.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Iterable + +from cli_anything.qgis.core import project as project_mod +from cli_anything.qgis.utils.qgis_backend import QgisBackendError, ensure_qgis_app + +FIELD_TYPES = { + "int": ("integer", "Int"), + "integer": ("integer", "Int"), + "double": ("double", "Double"), + "float": ("double", "Double"), + "string": ("string", "QString"), + "str": ("string", "QString"), + "bool": ("bool", "Bool"), + "boolean": ("bool", "Bool"), +} + +GEOMETRY_TYPES = { + "point": "Point", + "line": "LineString", + "linestring": "LineString", + "polygon": "Polygon", +} + + +def _field_type_enum(type_name: str): + from qgis.PyQt.QtCore import QMetaType + + normalized = type_name.strip().lower() + if normalized not in FIELD_TYPES: + raise QgisBackendError( + f"Unsupported field type: {type_name}. Use one of: int, double, string, bool" + ) + + enum_name = FIELD_TYPES[normalized][1] + return getattr(QMetaType.Type, enum_name), FIELD_TYPES[normalized][0] + + +def parse_field_specs(field_specs: Iterable[str]) -> list[dict]: + """Parse repeated name:type field specifications.""" + parsed: list[dict] = [] + seen_names: set[str] = set() + + for spec in field_specs: + name, separator, raw_type = spec.partition(":") + if not separator or not name.strip() or not raw_type.strip(): + raise QgisBackendError( + f"Invalid field specification: {spec}. Use name:type, e.g. name:string" + ) + + field_name = name.strip() + if field_name in seen_names: + raise QgisBackendError(f"Duplicate field name: {field_name}") + + field_type, normalized_type = _field_type_enum(raw_type) + parsed.append( + { + "name": field_name, + "meta_type": field_type, + "type": normalized_type, + } + ) + seen_names.add(field_name) + + return parsed + + +def _all_layers(): + return list(project_mod.current_project().mapLayers().values()) + + +def get_layer(identifier: str): + """Resolve a layer by id or exact name.""" + project = project_mod.current_project() + if identifier in project.mapLayers(): + return project.mapLayer(identifier) + + matches = [layer for layer in project.mapLayers().values() if layer.name() == identifier] + if not matches: + raise QgisBackendError(f"Layer not found: {identifier}") + if len(matches) > 1: + raise QgisBackendError( + f"Layer name is ambiguous: {identifier}. Use the layer id instead." + ) + return matches[0] + + +def _layer_type_name(layer) -> str: + from qgis.core import QgsMapLayerType + + if layer.type() == QgsMapLayerType.VectorLayer: + return "vector" + if layer.type() == QgsMapLayerType.RasterLayer: + return "raster" + return "other" + + +def _field_descriptions(layer) -> list[dict]: + descriptions = [] + for field in layer.fields(): + descriptions.append( + { + "name": field.name(), + "type": field.typeName() or str(field.type()), + } + ) + return descriptions + + +def layer_summary(layer) -> dict: + """Return a stable summary for a QGIS layer.""" + from qgis.core import QgsMapLayerType, QgsWkbTypes + + layer_type = _layer_type_name(layer) + summary = { + "id": layer.id(), + "name": layer.name(), + "type": layer_type, + "provider": layer.providerType(), + "source": layer.source(), + "crs": layer.crs().authid() if layer.crs().isValid() else None, + } + + if layer.type() == QgsMapLayerType.VectorLayer: + summary.update( + { + "geometry_type": QgsWkbTypes.displayString(layer.wkbType()), + "feature_count": int(layer.featureCount()), + "fields": _field_descriptions(layer), + } + ) + else: + summary.update( + { + "geometry_type": None, + "feature_count": None, + "fields": [], + } + ) + + return summary + + +def list_layers() -> dict: + """List all layers in the active project.""" + layers = sorted((layer_summary(layer) for layer in _all_layers()), key=lambda item: item["name"]) + return {"count": len(layers), "layers": layers} + + +def layer_info(identifier: str) -> dict: + """Return detailed information for a single layer.""" + return layer_summary(get_layer(identifier)) + + +def create_vector_layer( + name: str, + geometry: str, + crs: str, + field_specs: Iterable[str], +) -> dict: + """Create a GeoPackage-backed vector layer and add it to the current project.""" + ensure_qgis_app() + from qgis.core import ( + QgsCoordinateReferenceSystem, + QgsField, + QgsVectorFileWriter, + QgsVectorLayer, + ) + + if any(layer.name() == name for layer in _all_layers()): + raise QgisBackendError(f"Layer already exists: {name}") + + geometry_key = geometry.strip().lower() + if geometry_key not in GEOMETRY_TYPES: + raise QgisBackendError( + f"Unsupported geometry type: {geometry}. Use point, linestring, or polygon." + ) + + crs_value = QgsCoordinateReferenceSystem(crs) + if not crs_value.isValid(): + raise QgisBackendError(f"Invalid CRS: {crs}") + + fields = parse_field_specs(field_specs) + project = project_mod.current_project() + datastore_path = project_mod.default_datastore_path() + + memory_layer = QgsVectorLayer(f"{GEOMETRY_TYPES[geometry_key]}?crs={crs}", name, "memory") + if not memory_layer.isValid(): + raise QgisBackendError("Failed to create the in-memory source layer") + + provider = memory_layer.dataProvider() + provider.addAttributes([QgsField(field["name"], field["meta_type"]) for field in fields]) + memory_layer.updateFields() + + options = QgsVectorFileWriter.SaveVectorOptions() + options.driverName = "GPKG" + options.layerName = name + if Path(datastore_path).exists(): + options.actionOnExistingFile = QgsVectorFileWriter.CreateOrOverwriteLayer + + error_code, error_message, new_filename, new_layer = QgsVectorFileWriter.writeAsVectorFormatV3( + memory_layer, + datastore_path, + project.transformContext(), + options, + ) + if error_code != QgsVectorFileWriter.NoError: + raise QgisBackendError(error_message or f"Failed to write layer to {datastore_path}") + + stored_layer = QgsVectorLayer( + f"{new_filename or datastore_path}|layername={new_layer or name}", + name, + "ogr", + ) + if not stored_layer.isValid(): + raise QgisBackendError("Failed to reopen the GeoPackage-backed layer") + + project.addMapLayer(stored_layer) + return layer_summary(stored_layer) + + +def remove_layer(identifier: str) -> dict: + """Remove a layer from the active project.""" + project = project_mod.current_project() + layer = get_layer(identifier) + removed = layer_summary(layer) + project.removeMapLayer(layer.id()) + return removed diff --git a/QGIS/agent-harness/cli_anything/qgis/core/layouts.py b/QGIS/agent-harness/cli_anything/qgis/core/layouts.py new file mode 100644 index 0000000..76b7094 --- /dev/null +++ b/QGIS/agent-harness/cli_anything/qgis/core/layouts.py @@ -0,0 +1,195 @@ +"""Layout authoring helpers for cli-anything-qgis.""" + +from __future__ import annotations + +from cli_anything.qgis.core import project as project_mod +from cli_anything.qgis.utils.qgis_backend import QgisBackendError, ensure_qgis_app + +PAGE_SIZES = {"A4", "A3", "A2", "A1", "A0", "LETTER"} + + +def _layout_manager(): + return project_mod.current_project().layoutManager() + + +def _all_layouts(): + manager = _layout_manager() + if hasattr(manager, "printLayouts"): + return list(manager.printLayouts()) + if hasattr(manager, "layouts"): + return list(manager.layouts()) + return [] + + +def get_layout(name: str): + """Resolve a print layout by exact name.""" + matches = [layout for layout in _all_layouts() if layout.name() == name] + if not matches: + raise QgisBackendError(f"Layout not found: {name}") + if len(matches) > 1: + raise QgisBackendError(f"Layout name is ambiguous: {name}") + return matches[0] + + +def _item_summary(item) -> dict: + rect = item.sceneBoundingRect() + return { + "type": type(item).__name__, + "display_name": item.displayName() if hasattr(item, "displayName") else type(item).__name__, + "x": round(rect.x(), 2), + "y": round(rect.y(), 2), + "width": round(rect.width(), 2), + "height": round(rect.height(), 2), + } + + +def layout_summary(layout) -> dict: + """Return a stable summary for a print layout.""" + items = [_item_summary(item) for item in layout.items()] + return { + "name": layout.name(), + "item_count": len(items), + "items": items, + } + + +def list_layouts() -> dict: + """List print layouts in the active project.""" + layouts = sorted((layout_summary(layout) for layout in _all_layouts()), key=lambda item: item["name"]) + return {"count": len(layouts), "layouts": layouts} + + +def _combined_project_extent(): + from qgis.core import QgsMapLayerType, QgsRectangle + + extent = None + for layer in project_mod.current_project().mapLayers().values(): + if layer.type() != QgsMapLayerType.VectorLayer and layer.type() != QgsMapLayerType.RasterLayer: + continue + layer_extent = layer.extent() + if layer_extent.isNull() or not layer_extent.isFinite(): + continue + if extent is None: + extent = QgsRectangle(layer_extent) + else: + extent.combineExtentWith(layer_extent) + + if extent is None: + raise QgisBackendError( + "Could not determine a map extent. Add at least one layer or pass --extent explicitly." + ) + + if extent.width() == 0 and extent.height() == 0: + extent.grow(1.0) + + return extent + + +def _parse_extent(extent: str): + from qgis.core import QgsRectangle + + parts = [part.strip() for part in extent.split(",") if part.strip()] + if len(parts) != 4: + raise QgisBackendError( + f"Invalid extent: {extent}. Use xmin,ymin,xmax,ymax." + ) + xmin, ymin, xmax, ymax = map(float, parts) + return QgsRectangle(xmin, ymin, xmax, ymax) + + +def create_layout( + name: str, + page_size: str = "A4", + orientation: str = "portrait", +) -> dict: + """Create a new print layout.""" + ensure_qgis_app() + from qgis.core import QgsLayoutItemPage, QgsPrintLayout + + if any(layout.name() == name for layout in _all_layouts()): + raise QgisBackendError(f"Layout already exists: {name}") + + normalized_page_size = page_size.strip().upper() + if normalized_page_size not in PAGE_SIZES: + raise QgisBackendError( + f"Unsupported page size: {page_size}. Use one of: {', '.join(sorted(PAGE_SIZES))}" + ) + + normalized_orientation = orientation.strip().lower() + if normalized_orientation not in {"portrait", "landscape"}: + raise QgisBackendError("Orientation must be portrait or landscape") + + layout = QgsPrintLayout(project_mod.current_project()) + layout.initializeDefaults() + layout.setName(name) + page = layout.pageCollection().page(0) + page_orientation = ( + QgsLayoutItemPage.Landscape + if normalized_orientation == "landscape" + else QgsLayoutItemPage.Portrait + ) + if not page.setPageSize(normalized_page_size, page_orientation): + raise QgisBackendError(f"Failed to set page size: {page_size}") + + _layout_manager().addLayout(layout) + return layout_summary(layout) + + +def layout_info(name: str) -> dict: + """Return detailed information for a named layout.""" + return layout_summary(get_layout(name)) + + +def remove_layout(name: str) -> dict: + """Remove a print layout from the project.""" + layout = get_layout(name) + removed = layout_summary(layout) + _layout_manager().removeLayout(layout) + return removed + + +def add_map_item( + layout_name: str, + x: float, + y: float, + width: float, + height: float, + extent: str | None = None, +) -> dict: + """Add a map item to an existing layout.""" + ensure_qgis_app() + from qgis.core import QgsLayoutItemMap, QgsLayoutPoint, QgsLayoutSize, QgsUnitTypes + + layout = get_layout(layout_name) + map_item = QgsLayoutItemMap(layout) + map_item.attemptMove(QgsLayoutPoint(x, y, QgsUnitTypes.LayoutMillimeters)) + map_item.attemptResize(QgsLayoutSize(width, height, QgsUnitTypes.LayoutMillimeters)) + map_item.setExtent(_parse_extent(extent) if extent else _combined_project_extent()) + layout.addLayoutItem(map_item) + return layout_summary(layout) + + +def add_label_item( + layout_name: str, + text: str, + x: float, + y: float, + width: float, + height: float, + font_size: float = 18.0, +) -> dict: + """Add a label item to an existing layout.""" + ensure_qgis_app() + from qgis.PyQt.QtGui import QFont + from qgis.core import QgsLayoutItemLabel, QgsLayoutPoint, QgsLayoutSize, QgsUnitTypes + + layout = get_layout(layout_name) + label = QgsLayoutItemLabel(layout) + label.setText(text) + font = QFont() + font.setPointSizeF(font_size) + label.setFont(font) + label.attemptMove(QgsLayoutPoint(x, y, QgsUnitTypes.LayoutMillimeters)) + label.attemptResize(QgsLayoutSize(width, height, QgsUnitTypes.LayoutMillimeters)) + layout.addLayoutItem(label) + return layout_summary(layout) diff --git a/QGIS/agent-harness/cli_anything/qgis/core/processing.py b/QGIS/agent-harness/cli_anything/qgis/core/processing.py new file mode 100644 index 0000000..817bfb2 --- /dev/null +++ b/QGIS/agent-harness/cli_anything/qgis/core/processing.py @@ -0,0 +1,78 @@ +"""Processing wrappers around qgis_process.""" + +from __future__ import annotations + +from cli_anything.qgis.utils import qgis_backend as backend +from cli_anything.qgis.utils.qgis_backend import QgisBackendError + + +def parse_param_specs(param_specs: list[str]) -> list[str]: + """Validate repeated KEY=VALUE parameter specifications.""" + parameters: list[str] = [] + for spec in param_specs: + key, separator, value = spec.partition("=") + if not separator or not key.strip(): + raise QgisBackendError( + f"Invalid parameter specification: {spec}. Use KEY=VALUE." + ) + parameters.append(f"{key.strip()}={value}") + return parameters + + +def list_algorithms() -> dict: + """Return a flattened view of installed QGIS processing algorithms.""" + payload = backend.list_algorithms() + algorithms = [] + + for provider_id, provider in sorted(payload.get("providers", {}).items()): + for algorithm_id, details in sorted(provider.get("algorithms", {}).items()): + algorithms.append( + { + "id": algorithm_id, + "name": details.get("name"), + "provider": provider_id, + "group": details.get("group"), + "short_description": details.get("short_description"), + } + ) + + return { + "qgis_version": payload.get("qgis_version"), + "provider_count": len(payload.get("providers", {})), + "algorithm_count": len(algorithms), + "algorithms": algorithms, + } + + +def help_algorithm(algorithm_id: str) -> dict: + """Return structured help for a processing algorithm.""" + payload = backend.help_algorithm(algorithm_id) + return { + "qgis_version": payload.get("qgis_version"), + "provider": payload.get("provider_details", {}), + "algorithm": payload.get("algorithm_details", {}), + "parameters": payload.get("parameters", []), + "outputs": payload.get("outputs", []), + } + + +def run_algorithm( + algorithm_id: str, + *, + param_specs: list[str], + project_path: str | None = None, +) -> dict: + """Run a processing algorithm through qgis_process.""" + payload = backend.run_algorithm( + algorithm_id, + parameters=parse_param_specs(param_specs), + project_path=project_path, + ) + return { + "qgis_version": payload.get("qgis_version"), + "project_path": payload.get("project_path"), + "algorithm": payload.get("algorithm_details", {}), + "inputs": payload.get("inputs", {}), + "results": payload.get("results", {}), + "log": payload.get("log", []), + } diff --git a/QGIS/agent-harness/cli_anything/qgis/core/project.py b/QGIS/agent-harness/cli_anything/qgis/core/project.py new file mode 100644 index 0000000..aec27b6 --- /dev/null +++ b/QGIS/agent-harness/cli_anything/qgis/core/project.py @@ -0,0 +1,153 @@ +"""Project lifecycle helpers for cli-anything-qgis.""" + +from __future__ import annotations + +from pathlib import Path + +from cli_anything.qgis.utils.qgis_backend import QgisBackendError, ensure_qgis_app + + +def normalize_project_path(path: str) -> str: + """Normalize project paths and default to .qgz when no extension is given.""" + target = Path(path).expanduser() + if target.suffix.lower() not in {".qgs", ".qgz"}: + target = target.with_suffix(".qgz") + return str(target.resolve()) + + +def current_project(): + """Return the singleton QgsProject instance.""" + ensure_qgis_app() + from qgis.core import QgsProject + + return QgsProject.instance() + + +def current_project_path() -> str: + """Return the current project file path, if any.""" + return current_project().fileName() or "" + + +def require_saved_project_path() -> str: + """Return the active project path or raise if the project is unsaved.""" + project_path = current_project_path() + if not project_path: + raise QgisBackendError( + "No saved project is loaded. Create or open a project first, or pass --project." + ) + return project_path + + +def default_datastore_path(project_path: str | None = None) -> str: + """Return the default GeoPackage path for a saved project.""" + path = Path(project_path or require_saved_project_path()) + return str(path.with_name(f"{path.stem}_data.gpkg")) + + +def _layout_names(project) -> list[str]: + manager = project.layoutManager() + if hasattr(manager, "printLayouts"): + return sorted(layout.name() for layout in manager.printLayouts()) + if hasattr(manager, "layouts"): + return sorted(layout.name() for layout in manager.layouts()) + return [] + + +def project_info() -> dict: + """Return a summary of the active project.""" + project = current_project() + project_path = project.fileName() or "" + layer_names = sorted(layer.name() for layer in project.mapLayers().values()) + layout_names = _layout_names(project) + + return { + "path": project_path or None, + "title": project.title() or None, + "crs": project.crs().authid() if project.crs().isValid() else None, + "modified": bool(project.isDirty()), + "layer_count": len(layer_names), + "layout_count": len(layout_names), + "layer_names": layer_names, + "layout_names": layout_names, + "datastore_path": default_datastore_path(project_path) if project_path else None, + } + + +def create_project(output_path: str, title: str | None = None, crs: str = "EPSG:4326") -> dict: + """Create a new QGIS project and save it immediately.""" + ensure_qgis_app() + from qgis.core import QgsCoordinateReferenceSystem + + project = current_project() + normalized = normalize_project_path(output_path) + target = Path(normalized) + target.parent.mkdir(parents=True, exist_ok=True) + + project.clear() + project.setFileName(normalized) + project.setTitle(title or target.stem) + + crs_value = QgsCoordinateReferenceSystem(crs) + if not crs_value.isValid(): + raise QgisBackendError(f"Invalid CRS: {crs}") + project.setCrs(crs_value) + + if not project.write(): + raise QgisBackendError(f"Failed to create project at {normalized}") + + return project_info() + + +def open_project(project_path: str) -> dict: + """Load an existing QGIS project.""" + project = current_project() + normalized = normalize_project_path(project_path) + target = Path(normalized) + if not target.exists(): + raise QgisBackendError(f"Project does not exist: {normalized}") + + project.clear() + if not project.read(normalized): + raise QgisBackendError(f"Failed to open project: {normalized}") + + return project_info() + + +def save_project(output_path: str | None = None) -> dict: + """Save the active QGIS project.""" + project = current_project() + target_path = normalize_project_path(output_path) if output_path else current_project_path() + if not target_path: + raise QgisBackendError("No project file path is set. Use project save PATH.") + + target = Path(target_path) + target.parent.mkdir(parents=True, exist_ok=True) + project.setFileName(target_path) + + if not project.write(): + raise QgisBackendError(f"Failed to save project: {target_path}") + + return project_info() + + +def save_if_dirty() -> dict: + """Save the active project when it is dirty or missing on disk.""" + project = current_project() + target_path = require_saved_project_path() + if project.isDirty() or not Path(target_path).exists(): + return save_project(target_path) + return project_info() + + +def set_project_crs(crs: str) -> dict: + """Set the active project CRS.""" + ensure_qgis_app() + from qgis.core import QgsCoordinateReferenceSystem + + project = current_project() + crs_value = QgsCoordinateReferenceSystem(crs) + if not crs_value.isValid(): + raise QgisBackendError(f"Invalid CRS: {crs}") + + project.setCrs(crs_value) + return project_info() diff --git a/QGIS/agent-harness/cli_anything/qgis/core/session.py b/QGIS/agent-harness/cli_anything/qgis/core/session.py new file mode 100644 index 0000000..bc58326 --- /dev/null +++ b/QGIS/agent-harness/cli_anything/qgis/core/session.py @@ -0,0 +1,142 @@ +"""Session state and command history for cli-anything-qgis.""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path + + +def _locked_save_json(path: str, data: dict, **dump_kwargs) -> None: + """Atomically write JSON with exclusive file locking.""" + try: + handle = open(path, "r+", encoding="utf-8") + except FileNotFoundError: + os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) + handle = open(path, "w", encoding="utf-8") + + with handle: + locked = False + try: + import fcntl + + fcntl.flock(handle.fileno(), fcntl.LOCK_EX) + locked = True + except (ImportError, OSError): + pass + + try: + handle.seek(0) + handle.truncate() + json.dump(data, handle, **dump_kwargs) + handle.flush() + finally: + if locked: + import fcntl + + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + + +@dataclass +class HistoryEntry: + """A single CLI command execution.""" + + command: str + args: dict + timestamp: str = "" + result: dict | None = None + + def __post_init__(self) -> None: + if not self.timestamp: + self.timestamp = datetime.now(timezone.utc).isoformat() + + def to_dict(self) -> dict: + return { + "command": self.command, + "args": self.args, + "timestamp": self.timestamp, + "result": self.result, + } + + @classmethod + def from_dict(cls, value: dict) -> "HistoryEntry": + return cls( + command=value["command"], + args=value.get("args", {}), + timestamp=value.get("timestamp", ""), + result=value.get("result"), + ) + + +class Session: + """Tracks the current project path and command history.""" + + def __init__(self, session_file: str | None = None): + self.current_project_path = "" + self._history: list[HistoryEntry] = [] + self._session_file = session_file + if session_file: + self._load(session_file) + + @property + def history_count(self) -> int: + return len(self._history) + + @property + def active_project_name(self) -> str: + if not self.current_project_path: + return "" + return Path(self.current_project_path).name + + def set_project_path(self, path: str | None) -> None: + normalized = str(path or "") + if normalized == self.current_project_path: + return + self.current_project_path = normalized + self._auto_save() + + def clear_project(self) -> None: + if not self.current_project_path: + return + self.current_project_path = "" + self._auto_save() + + def record(self, command: str, args: dict, result: dict | None = None) -> None: + self._history.append(HistoryEntry(command=command, args=args, result=result)) + self._auto_save() + + def history(self, limit: int = 20) -> list[dict]: + entries = self._history[-limit:] if limit else self._history + return [entry.to_dict() for entry in entries] + + def status(self, *, modified: bool = False) -> dict: + return { + "current_project_path": self.current_project_path or None, + "project_name": self.active_project_name or None, + "modified": modified, + "history_count": self.history_count, + } + + def save(self, path: str) -> None: + data = { + "current_project_path": self.current_project_path, + "history": [entry.to_dict() for entry in self._history], + } + _locked_save_json(path, data, indent=2, sort_keys=True) + + def _auto_save(self) -> None: + if self._session_file: + self.save(self._session_file) + + def _load(self, path: str) -> None: + session_path = Path(path) + if not session_path.exists(): + return + try: + data = json.loads(session_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return + self.current_project_path = str(data.get("current_project_path", "") or "") + self._history = [HistoryEntry.from_dict(entry) for entry in data.get("history", [])] diff --git a/QGIS/agent-harness/cli_anything/qgis/qgis_cli.py b/QGIS/agent-harness/cli_anything/qgis/qgis_cli.py new file mode 100644 index 0000000..1cabb3c --- /dev/null +++ b/QGIS/agent-harness/cli_anything/qgis/qgis_cli.py @@ -0,0 +1,750 @@ +#!/usr/bin/env python3 +"""QGIS CLI — stateful project, layer, layout, export, and processing commands. + +Usage: + # One-shot commands + cli-anything-qgis project new -o demo.qgz --title "Demo" + cli-anything-qgis --project demo.qgz layer create-vector --name places --geometry point --field name:string + cli-anything-qgis --project demo.qgz feature add --layer places --wkt "POINT(1 2)" --attr name=HQ + cli-anything-qgis --project demo.qgz layout create --name Main + cli-anything-qgis --project demo.qgz export pdf out.pdf --layout Main --overwrite + cli-anything-qgis --json process help native:printlayouttopdf + + # Interactive REPL + cli-anything-qgis +""" + +from __future__ import annotations + +import functools +import json +import shlex +import sys +from pathlib import Path +from typing import Optional + +import click + +from cli_anything.qgis import __version__ +from cli_anything.qgis.core import export as export_mod +from cli_anything.qgis.core import features as features_mod +from cli_anything.qgis.core import layers as layers_mod +from cli_anything.qgis.core import layouts as layouts_mod +from cli_anything.qgis.core import processing as processing_mod +from cli_anything.qgis.core import project as project_mod +from cli_anything.qgis.core.session import Session +from cli_anything.qgis.utils.qgis_backend import QgisBackendError, QgisProcessError + +_session: Optional[Session] = None +_json_output = False +_repl_mode = False + + +def get_session() -> Session: + """Return the process-local session object.""" + global _session + if _session is None: + session_dir = Path.home() / ".cli-anything-qgis" + session_dir.mkdir(parents=True, exist_ok=True) + _session = Session(str(session_dir / "session.json")) + return _session + + +def _print_dict(data: dict, indent: int = 0) -> None: + prefix = " " * indent + for key, value in data.items(): + if isinstance(value, dict): + click.echo(f"{prefix}{key}:") + _print_dict(value, indent + 1) + elif isinstance(value, list): + click.echo(f"{prefix}{key}:") + _print_list(value, indent + 1) + else: + click.echo(f"{prefix}{key}: {value}") + + +def _print_list(items: list, indent: int = 0) -> None: + prefix = " " * indent + for index, item in enumerate(items): + if isinstance(item, dict): + click.echo(f"{prefix}[{index}]") + _print_dict(item, indent + 1) + else: + click.echo(f"{prefix}- {item}") + + +def output(data, message: str = "") -> None: + """Emit data in either JSON or human-readable form.""" + if _json_output: + click.echo(json.dumps(data, indent=2, default=str)) + return + + if message: + click.echo(message) + if isinstance(data, dict): + _print_dict(data) + elif isinstance(data, list): + _print_list(data) + else: + click.echo(str(data)) + + +def _error_payload(exc: Exception) -> dict: + payload = { + "error": str(exc), + "type": exc.__class__.__name__, + } + if isinstance(exc, QgisProcessError): + payload["returncode"] = exc.returncode + if exc.stderr: + payload["stderr"] = exc.stderr + if exc.stdout: + payload["stdout"] = exc.stdout + if exc.payload: + payload["payload"] = exc.payload + return payload + + +def handle_error(func): + """Normalize domain/backend errors for CLI and REPL use.""" + + @functools.wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except (QgisBackendError, QgisProcessError, ValueError) as exc: + payload = _error_payload(exc) + if _json_output: + click.echo(json.dumps(payload, indent=2, default=str)) + else: + click.echo(f"Error: {exc}", err=True) + if not _repl_mode: + raise SystemExit(1) + return None + + return wrapper + + +def _requested_project_path() -> str | None: + ctx = click.get_current_context(silent=True) + if ctx is None: + return None + root = ctx.find_root() + obj = root.obj or {} + return obj.get("project_path") + + +def _sync_session_project_path() -> None: + session = get_session() + current_path = project_mod.current_project_path() + if current_path: + session.set_project_path(current_path) + else: + session.clear_project() + + +def _current_project_modified() -> bool: + try: + return bool(project_mod.current_project().isDirty()) + except Exception: + return False + + +def _load_requested_project(required: bool = False) -> str | None: + requested = _requested_project_path() + if requested: + normalized = project_mod.normalize_project_path(requested) + if project_mod.current_project_path() != normalized: + project_mod.open_project(normalized) + _sync_session_project_path() + return normalized + + if required and not project_mod.current_project_path(): + raise QgisBackendError( + "No project is loaded. Open one with project open or pass --project." + ) + + return project_mod.current_project_path() or None + + +def _active_project_path(required: bool = False) -> str | None: + requested = _requested_project_path() + if requested: + return project_mod.normalize_project_path(requested) + + current = project_mod.current_project_path() + if current: + return current + + if required: + raise QgisBackendError( + "No project is loaded. Open one with project open or pass --project." + ) + return None + + +def _auto_save_if_one_shot() -> None: + if _repl_mode: + return + if project_mod.current_project_path(): + project_mod.save_project() + _sync_session_project_path() + + +def _record(command: str, args: dict, result=None) -> None: + summary = None + if isinstance(result, dict): + summary_keys = { + "path", + "output", + "format", + "title", + "layer_count", + "layout_count", + "feature_count", + "count", + "name", + } + summary = {key: value for key, value in result.items() if key in summary_keys} + if not summary and "layer" in result and isinstance(result["layer"], dict): + summary = {"layer": result["layer"].get("name")} + get_session().record(command, args, summary) + + +@click.group(invoke_without_command=True) +@click.option("--json", "use_json", is_flag=True, help="Output as JSON") +@click.option( + "--project", + "project_path", + type=click.Path(path_type=Path), + default=None, + help="Open this project for the current command.", +) +@click.pass_context +def cli(ctx, use_json, project_path): + """QGIS CLI for project authoring, layout export, and processing.""" + global _json_output + _json_output = use_json + get_session() + ctx.obj = {"project_path": str(project_path) if project_path else None} + + if ctx.invoked_subcommand is None and "--help" not in sys.argv and "-h" not in sys.argv: + ctx.invoke(repl) + + +@cli.group() +def project(): + """Project management commands.""" + + +@project.command("new") +@click.option("-o", "--output", "output_path", required=True, type=click.Path(path_type=Path)) +@click.option("--title", default=None, help="Project title") +@click.option("--crs", default="EPSG:4326", help="Project CRS, e.g. EPSG:4326") +@handle_error +def project_new(output_path: Path, title: str | None, crs: str): + """Create a new saved QGIS project.""" + data = project_mod.create_project(str(output_path), title=title, crs=crs) + _sync_session_project_path() + _record("project new", {"output": str(output_path), "title": title, "crs": crs}, data) + output(data, f"Created project: {data['path']}") + + +@project.command("open") +@click.argument("project_path", type=click.Path(path_type=Path)) +@handle_error +def project_open(project_path: Path): + """Open an existing QGIS project.""" + data = project_mod.open_project(str(project_path)) + _sync_session_project_path() + _record("project open", {"project_path": str(project_path)}, data) + output(data, f"Opened project: {data['path']}") + + +@project.command("save") +@click.argument("output_path", required=False, type=click.Path(path_type=Path)) +@handle_error +def project_save(output_path: Path | None): + """Save the current QGIS project.""" + if output_path is None: + _load_requested_project(required=bool(_requested_project_path())) + data = project_mod.save_project(str(output_path) if output_path else None) + _sync_session_project_path() + _record("project save", {"output": str(output_path) if output_path else None}, data) + output(data, f"Saved project: {data['path']}") + + +@project.command("info") +@handle_error +def project_info(): + """Show information about the current QGIS project.""" + if _requested_project_path(): + _load_requested_project(required=True) + data = project_mod.project_info() + _record("project info", {}, data) + output(data) + + +@project.command("set-crs") +@click.argument("crs") +@handle_error +def project_set_crs(crs: str): + """Set the active project's CRS.""" + _load_requested_project(required=True) + data = project_mod.set_project_crs(crs) + _auto_save_if_one_shot() + _record("project set-crs", {"crs": crs}, data) + output(data, f"Updated project CRS to {crs}") + + +@cli.group() +def layer(): + """Layer management commands.""" + + +@layer.command("create-vector") +@click.option("--name", required=True, help="Layer name") +@click.option( + "--geometry", + required=True, + type=click.Choice(["point", "linestring", "polygon"], case_sensitive=False), + help="Geometry type", +) +@click.option("--crs", default=None, help="Layer CRS, e.g. EPSG:4326") +@click.option("--field", "field_specs", multiple=True, help="Field spec as name:type") +@handle_error +def layer_create_vector(name: str, geometry: str, crs: str | None, field_specs: tuple[str, ...]): + """Create a GeoPackage-backed vector layer in the current project.""" + _load_requested_project(required=True) + effective_crs = crs or project_mod.project_info().get("crs") or "EPSG:4326" + data = layers_mod.create_vector_layer(name, geometry, effective_crs, field_specs) + _auto_save_if_one_shot() + _record( + "layer create-vector", + { + "name": name, + "geometry": geometry, + "crs": effective_crs, + "fields": list(field_specs), + }, + data, + ) + output(data, f"Created layer: {data['name']}") + + +@layer.command("list") +@handle_error +def layer_list(): + """List layers in the current project.""" + _load_requested_project(required=True) + data = layers_mod.list_layers() + _record("layer list", {}, data) + output(data) + + +@layer.command("info") +@click.argument("identifier") +@handle_error +def layer_info(identifier: str): + """Show detailed information for a layer.""" + _load_requested_project(required=True) + data = layers_mod.layer_info(identifier) + _record("layer info", {"identifier": identifier}, data) + output(data) + + +@layer.command("remove") +@click.argument("identifier") +@handle_error +def layer_remove(identifier: str): + """Remove a layer from the current project.""" + _load_requested_project(required=True) + data = layers_mod.remove_layer(identifier) + _auto_save_if_one_shot() + _record("layer remove", {"identifier": identifier}, data) + output(data, f"Removed layer: {data['name']}") + + +@cli.group() +def feature(): + """Feature editing commands.""" + + +@feature.command("add") +@click.option("--layer", "layer_identifier", required=True, help="Target layer id or name") +@click.option("--wkt", required=True, help="Geometry in WKT format") +@click.option("--attr", "attr_specs", multiple=True, help="Feature attribute as key=value") +@handle_error +def feature_add(layer_identifier: str, wkt: str, attr_specs: tuple[str, ...]): + """Add a feature to a vector layer using WKT geometry.""" + _load_requested_project(required=True) + data = features_mod.add_feature(layer_identifier, wkt, list(attr_specs)) + _auto_save_if_one_shot() + _record( + "feature add", + {"layer": layer_identifier, "wkt": wkt, "attrs": list(attr_specs)}, + data, + ) + output(data, f"Added feature to {data['layer']['name']}") + + +@feature.command("list") +@click.option("--layer", "layer_identifier", required=True, help="Target layer id or name") +@click.option("--limit", default=20, show_default=True, type=int, help="Maximum features to show") +@handle_error +def feature_list(layer_identifier: str, limit: int): + """List features from a vector layer.""" + _load_requested_project(required=True) + data = features_mod.list_features(layer_identifier, limit=limit) + _record("feature list", {"layer": layer_identifier, "limit": limit}, data) + output(data) + + +@cli.group() +def layout(): + """Print layout authoring commands.""" + + +@layout.command("create") +@click.option("--name", required=True, help="Layout name") +@click.option("--page-size", default="A4", show_default=True, help="Page size") +@click.option( + "--orientation", + default="portrait", + show_default=True, + type=click.Choice(["portrait", "landscape"], case_sensitive=False), + help="Page orientation", +) +@handle_error +def layout_create(name: str, page_size: str, orientation: str): + """Create a print layout.""" + _load_requested_project(required=True) + data = layouts_mod.create_layout(name, page_size=page_size, orientation=orientation) + _auto_save_if_one_shot() + _record( + "layout create", + {"name": name, "page_size": page_size, "orientation": orientation}, + data, + ) + output(data, f"Created layout: {name}") + + +@layout.command("list") +@handle_error +def layout_list(): + """List print layouts in the current project.""" + _load_requested_project(required=True) + data = layouts_mod.list_layouts() + _record("layout list", {}, data) + output(data) + + +@layout.command("info") +@click.argument("name") +@handle_error +def layout_info(name: str): + """Show detailed information for a print layout.""" + _load_requested_project(required=True) + data = layouts_mod.layout_info(name) + _record("layout info", {"name": name}, data) + output(data) + + +@layout.command("remove") +@click.argument("name") +@handle_error +def layout_remove(name: str): + """Remove a print layout from the project.""" + _load_requested_project(required=True) + data = layouts_mod.remove_layout(name) + _auto_save_if_one_shot() + _record("layout remove", {"name": name}, data) + output(data, f"Removed layout: {name}") + + +@layout.command("add-map") +@click.option("--layout", "layout_name", required=True, help="Layout name") +@click.option("--x", type=float, required=True, help="Left position in millimeters") +@click.option("--y", type=float, required=True, help="Top position in millimeters") +@click.option("--width", type=float, required=True, help="Item width in millimeters") +@click.option("--height", type=float, required=True, help="Item height in millimeters") +@click.option("--extent", default=None, help="Map extent as xmin,ymin,xmax,ymax") +@handle_error +def layout_add_map(layout_name: str, x: float, y: float, width: float, height: float, extent: str | None): + """Add a map item to a print layout.""" + _load_requested_project(required=True) + data = layouts_mod.add_map_item(layout_name, x, y, width, height, extent=extent) + _auto_save_if_one_shot() + _record( + "layout add-map", + { + "layout": layout_name, + "x": x, + "y": y, + "width": width, + "height": height, + "extent": extent, + }, + data, + ) + output(data, f"Added map item to layout: {layout_name}") + + +@layout.command("add-label") +@click.option("--layout", "layout_name", required=True, help="Layout name") +@click.option("--text", required=True, help="Label text") +@click.option("--x", type=float, required=True, help="Left position in millimeters") +@click.option("--y", type=float, required=True, help="Top position in millimeters") +@click.option("--width", type=float, required=True, help="Item width in millimeters") +@click.option("--height", type=float, required=True, help="Item height in millimeters") +@click.option("--font-size", default=18.0, show_default=True, type=float, help="Font size") +@handle_error +def layout_add_label( + layout_name: str, + text: str, + x: float, + y: float, + width: float, + height: float, + font_size: float, +): + """Add a label item to a print layout.""" + _load_requested_project(required=True) + data = layouts_mod.add_label_item(layout_name, text, x, y, width, height, font_size=font_size) + _auto_save_if_one_shot() + _record( + "layout add-label", + { + "layout": layout_name, + "text": text, + "x": x, + "y": y, + "width": width, + "height": height, + "font_size": font_size, + }, + data, + ) + output(data, f"Added label item to layout: {layout_name}") + + +@cli.group() +def export(): + """Layout export commands.""" + + +@export.command("presets") +@handle_error +def export_presets(): + """List supported export formats and their backend algorithms.""" + data = export_mod.export_presets() + _record("export presets", {}, data) + output(data) + + +@export.command("pdf") +@click.argument("output_path", type=click.Path(path_type=Path)) +@click.option("--layout", "layout_name", required=True, help="Layout name") +@click.option("--dpi", default=None, type=float, help="Override layout DPI") +@click.option("--force-vector", is_flag=True, help="Always export vector output") +@click.option("--force-raster", is_flag=True, help="Always rasterize the PDF") +@click.option("--georeference/--no-georeference", default=True, help="Append georeference metadata") +@click.option("--overwrite", is_flag=True, help="Overwrite existing output") +@handle_error +def export_pdf( + output_path: Path, + layout_name: str, + dpi: float | None, + force_vector: bool, + force_raster: bool, + georeference: bool, + overwrite: bool, +): + """Export a named print layout as PDF.""" + _load_requested_project(required=True) + data = export_mod.export_layout_pdf( + str(output_path), + layout_name=layout_name, + dpi=dpi, + force_vector=force_vector, + force_raster=force_raster, + georeference=georeference, + overwrite=overwrite, + ) + _record( + "export pdf", + { + "output": str(output_path), + "layout": layout_name, + "dpi": dpi, + "force_vector": force_vector, + "force_raster": force_raster, + "georeference": georeference, + "overwrite": overwrite, + }, + data, + ) + output(data, f"Exported PDF: {data['output']}") + + +@export.command("image") +@click.argument("output_path", type=click.Path(path_type=Path)) +@click.option("--layout", "layout_name", required=True, help="Layout name") +@click.option("--dpi", default=None, type=float, help="Override layout DPI") +@click.option("--overwrite", is_flag=True, help="Overwrite existing output") +@handle_error +def export_image(output_path: Path, layout_name: str, dpi: float | None, overwrite: bool): + """Export a named print layout as an image file.""" + _load_requested_project(required=True) + data = export_mod.export_layout_image( + str(output_path), + layout_name=layout_name, + dpi=dpi, + overwrite=overwrite, + ) + _record( + "export image", + { + "output": str(output_path), + "layout": layout_name, + "dpi": dpi, + "overwrite": overwrite, + }, + data, + ) + output(data, f"Exported image: {data['output']}") + + +@cli.group() +def process(): + """Generic qgis_process discovery and execution commands.""" + + +@process.command("list") +@handle_error +def process_list(): + """List installed QGIS processing algorithms.""" + data = processing_mod.list_algorithms() + _record("process list", {}, {"count": data.get("algorithm_count")}) + output(data) + + +@process.command("help") +@click.argument("algorithm_id") +@handle_error +def process_help(algorithm_id: str): + """Show parameter and output details for a processing algorithm.""" + data = processing_mod.help_algorithm(algorithm_id) + _record("process help", {"algorithm_id": algorithm_id}, data) + output(data) + + +@process.command("run") +@click.argument("algorithm_id") +@click.option("--param", "param_specs", multiple=True, help="Algorithm parameter as KEY=VALUE") +@handle_error +def process_run(algorithm_id: str, param_specs: tuple[str, ...]): + """Run a QGIS processing algorithm through qgis_process.""" + data = processing_mod.run_algorithm( + algorithm_id, + param_specs=list(param_specs), + project_path=_active_project_path(required=False), + ) + _record("process run", {"algorithm_id": algorithm_id, "params": list(param_specs)}, data) + output(data) + + +@cli.group() +def session(): + """Session state and history commands.""" + + +@session.command("status") +@handle_error +def session_status(): + """Show the current session status.""" + _sync_session_project_path() + data = get_session().status(modified=_current_project_modified()) + _record("session status", {}, data) + output(data) + + +@session.command("history") +@click.option("--limit", default=20, show_default=True, type=int, help="Maximum history entries to show") +@handle_error +def session_history(limit: int): + """Show recent command history.""" + data = {"history": get_session().history(limit=limit)} + _record("session history", {"limit": limit}, {"count": len(data['history'])}) + output(data) + + +@cli.command() +@handle_error +def repl(): + """Start the interactive REPL.""" + from cli_anything.qgis.utils.repl_skin import ReplSkin + + global _repl_mode + _repl_mode = True + + skin = ReplSkin("qgis", version=__version__) + skin.print_banner() + prompt_session = skin.create_prompt_session() + + command_help = { + "project": "new|open|save|info|set-crs", + "layer": "create-vector|list|info|remove", + "feature": "add|list", + "layout": "create|list|info|remove|add-map|add-label", + "export": "presets|pdf|image", + "process": "list|help|run", + "session": "status|history", + "help": "Show this help", + "quit": "Exit REPL", + } + + try: + while True: + _sync_session_project_path() + session_state = get_session() + line = skin.get_input( + prompt_session, + project_name=session_state.active_project_name, + modified=_current_project_modified(), + ) + if not line: + continue + lowered = line.lower() + if lowered in {"quit", "exit", "q"}: + skin.print_goodbye() + break + if lowered == "help": + skin.help(command_help) + continue + + try: + args = shlex.split(line) + except ValueError as exc: + skin.error(str(exc)) + continue + + try: + cli.main(args=args, standalone_mode=False) + except SystemExit: + pass + except click.ClickException as exc: + skin.error(str(exc)) + except Exception as exc: # pragma: no cover - last-resort REPL guard + skin.error(str(exc)) + finally: + _repl_mode = False + + +def main() -> None: + """CLI entry point for setuptools.""" + cli() + + +if __name__ == "__main__": + main() diff --git a/QGIS/agent-harness/cli_anything/qgis/skills/SKILL.md b/QGIS/agent-harness/cli_anything/qgis/skills/SKILL.md new file mode 100644 index 0000000..8df56e8 --- /dev/null +++ b/QGIS/agent-harness/cli_anything/qgis/skills/SKILL.md @@ -0,0 +1,140 @@ +--- +name: cli-anything-qgis +description: Stateful QGIS CLI for projects, writable layers, features, layouts, exports, and qgis_process operations using the real QGIS runtime. +--- + +# cli-anything-qgis + +Use this skill when you need to inspect or modify QGIS projects from the terminal through the real QGIS runtime. + +## Requirements + +- QGIS installed with `qgis_process` on `PATH` +- PyQGIS importable from the Python environment running the CLI +- Python 3.10+ + +## Agent guidance + +- Prefer `--json` for all machine-driven use. +- For one-shot commands, pass `--project ` when operating on an existing project. +- Running `cli-anything-qgis` with no subcommand starts a stateful REPL. +- Layout export is backed by real QGIS processing algorithms: + - `native:printlayouttopdf` + - `native:printlayouttoimage` + +## What this CLI covers + +- create, open, save, and inspect `.qgs` / `.qgz` projects +- create writable GeoPackage-backed vector layers +- add features via WKT geometry and typed `key=value` attributes +- create print layouts and add map/label items +- export layouts to PDF or image +- inspect and run generic `qgis_process` algorithms +- inspect session status and history + +## Command groups + +### `project` +- `new -o/--output [--title] [--crs]` +- `open PATH` +- `save [PATH]` +- `info` +- `set-crs CRS` + +### `layer` +- `create-vector --name --geometry --crs [--field name:type ...]` +- `list` +- `info LAYER` +- `remove LAYER` + +### `feature` +- `add --layer LAYER --wkt WKT [--attr key=value ...]` +- `list --layer LAYER [--limit N]` + +### `layout` +- `create --name [--page-size] [--orientation]` +- `list` +- `info LAYOUT` +- `remove LAYOUT` +- `add-map --layout LAYOUT --x --y --width --height [--extent xmin,ymin,xmax,ymax]` +- `add-label --layout LAYOUT --text TEXT --x --y --width --height [--font-size N]` + +### `export` +- `presets` +- `pdf OUTPUT --layout LAYOUT [--dpi] [--force-vector] [--force-raster] [--georeference/--no-georeference] [--overwrite]` +- `image OUTPUT --layout LAYOUT [--dpi] [--overwrite]` + +### `process` +- `list` +- `help ALGORITHM_ID` +- `run ALGORITHM_ID [--param KEY=VALUE ...]` + +### `session` +- `status` +- `history [--limit N]` + +## Examples + +### Create a project and add a writable layer + +```bash +cli-anything-qgis --json project new -o demo.qgz --title "Demo" --crs EPSG:4326 +cli-anything-qgis --json --project demo.qgz layer create-vector \ + --name places \ + --geometry point \ + --field name:string \ + --field score:int +``` + +### Add features and inspect them + +```bash +cli-anything-qgis --json --project demo.qgz feature add \ + --layer places \ + --wkt "POINT(1 2)" \ + --attr name=HQ \ + --attr score=5 + +cli-anything-qgis --json --project demo.qgz feature list --layer places --limit 10 +``` + +### Create and export a layout + +```bash +cli-anything-qgis --json --project demo.qgz layout create --name Main +cli-anything-qgis --json --project demo.qgz layout add-map --layout Main --x 10 --y 20 --width 180 --height 120 +cli-anything-qgis --json --project demo.qgz layout add-label --layout Main --text "Demo map" --x 10 --y 8 --width 100 --height 10 +cli-anything-qgis --json --project demo.qgz export pdf output.pdf --layout Main --overwrite +``` + +### Inspect or run processing algorithms + +```bash +cli-anything-qgis --json process help native:buffer +cli-anything-qgis --json --project demo.qgz process run native:buffer \ + --param INPUT=/tmp/demo_data.gpkg|layername=places \ + --param DISTANCE=1 \ + --param SEGMENTS=8 \ + --param END_CAP_STYLE=0 \ + --param JOIN_STYLE=0 \ + --param MITER_LIMIT=2 \ + --param DISSOLVE=false \ + --param OUTPUT=/tmp/buffer.geojson +``` + +### REPL + +```bash +cli-anything-qgis +``` + +Example interactive flow: + +```text +project new -o demo.qgz --title "Demo" +layer create-vector --name places --geometry point --field name:string +feature add --layer places --wkt "POINT(1 2)" --attr name=HQ +layout create --name Main +session status +quit +``` diff --git a/QGIS/agent-harness/cli_anything/qgis/tests/TEST.md b/QGIS/agent-harness/cli_anything/qgis/tests/TEST.md new file mode 100644 index 0000000..a34283a --- /dev/null +++ b/QGIS/agent-harness/cli_anything/qgis/tests/TEST.md @@ -0,0 +1,157 @@ +# cli-anything-qgis test plan + +## Scope + +This test plan covers the production-style QGIS harness under `cli_anything/qgis/`. + +The suite is split into: + +- `test_core.py` for direct module tests against PyQGIS helpers and backend wrappers +- `test_full_e2e.py` for real workflow coverage and installed-command subprocess coverage + +## Planned unit and module coverage + +### Backend helpers +- `find_qgis_process()` returns a usable executable path or raises a clear error +- `project_path_argument()` normalizes project paths +- `run_process_json()` normalizes JSON and failure cases + +### Project helpers +- create a new project +- open an existing project +- save a project to a path +- change project CRS +- derive the default datastore path +- summarize project metadata + +### Layer helpers +- parse field specs +- create GeoPackage-backed vector layers with fields +- list and inspect layers +- remove layers + +### Feature helpers +- add features using WKT and typed attributes +- list features with limits +- validate bad attr specifications and bad booleans + +### Layout helpers +- create layouts with page size/orientation +- list and inspect layouts +- add map items +- add label items +- remove layouts + +### Session helpers +- record command history +- save/load session history +- report session status + +## Planned real end-to-end workflows + +### Workflow 1 — scratch project to PDF +1. create a new project +2. create a writable vector layer +3. add features +4. create a layout +5. add a map item and label +6. export PDF +7. verify file exists, has non-zero size, and starts with `%PDF-` + +### Workflow 2 — scratch project to PNG +1. create a new project +2. create content and layout +3. export image +4. verify file exists, PNG signature is valid, and dimensions are positive + +### Workflow 3 — processing passthrough +1. create a vector layer and add features +2. run `native:buffer` through the harness +3. verify output dataset exists and opens as a valid vector layer +4. verify buffered feature count is positive + +## Planned subprocess coverage + +Subprocess tests must target the installed executable via `_resolve_cli("cli-anything-qgis")`. + +Planned checks: + +- `cli-anything-qgis --help` +- `cli-anything-qgis --json process help native:printlayouttopdf` +- `cli-anything-qgis --json project new -o ...` +- full installed-command PDF workflow +- full installed-command PNG workflow + +## Planned execution commands + +```bash +CLI_ANYTHING_FORCE_INSTALLED=1 python3 -m pytest cli_anything/qgis/tests/test_core.py -v +CLI_ANYTHING_FORCE_INSTALLED=1 python3 -m pytest cli_anything/qgis/tests/test_full_e2e.py -v -s +CLI_ANYTHING_FORCE_INSTALLED=1 python3 -m pytest cli_anything/qgis/tests -v -s --tb=no +``` + +## Results + +### Coverage note + +- Direct core tests covered project, layer, feature, layout, session, and backend error-normalization helpers, including a point-only layout auto-extent regression. +- Real E2E coverage exercised PDF export, PNG export, and `native:buffer` processing against the installed QGIS runtime. +- Subprocess coverage verified the installed `cli-anything-qgis` entrypoint, JSON help output, project creation, full PDF/PNG workflows, and the point-only `layout add-map` regression path. +- One known warning remains: `QgsLayoutItemLabel.setFont()` is deprecated in this QGIS build, but exports and tests pass. + +### `pytest -v -s --tb=no` output + +```text +============================= test session starts ============================== +platform linux -- Python 3.12.3, pytest-9.0.2, pluggy-1.6.0 -- /home/wangh68/project/cli_anything_g/QGIS/agent-harness/.venv/bin/python +cachedir: .pytest_cache +rootdir: /home/wangh68/project/cli_anything_g/QGIS/agent-harness +configfile: pytest.ini +plugins: cov-7.1.0, anyio-4.12.1 +collecting ... collected 22 items + +QGIS/agent-harness/cli_anything/qgis/tests/test_core.py::test_project_create_save_open_and_info PASSED +QGIS/agent-harness/cli_anything/qgis/tests/test_core.py::test_default_datastore_path PASSED +QGIS/agent-harness/cli_anything/qgis/tests/test_core.py::test_parse_field_and_param_specs PASSED +QGIS/agent-harness/cli_anything/qgis/tests/test_core.py::test_layer_create_list_info_and_remove PASSED +QGIS/agent-harness/cli_anything/qgis/tests/test_core.py::test_feature_add_and_list PASSED +QGIS/agent-harness/cli_anything/qgis/tests/test_core.py::test_feature_add_rejects_invalid_boolean PASSED +QGIS/agent-harness/cli_anything/qgis/tests/test_core.py::test_layout_create_add_items_and_remove PASSED +QGIS/agent-harness/cli_anything/qgis/tests/test_core.py::test_layout_add_map_accepts_point_only_project PASSED +QGIS/agent-harness/cli_anything/qgis/tests/test_core.py::test_export_presets_describe_supported_formats PASSED +QGIS/agent-harness/cli_anything/qgis/tests/test_core.py::test_session_save_load_and_status PASSED +QGIS/agent-harness/cli_anything/qgis/tests/test_core.py::test_project_path_argument_normalizes PASSED +QGIS/agent-harness/cli_anything/qgis/tests/test_core.py::test_find_qgis_process_missing_raises_clear_error PASSED +QGIS/agent-harness/cli_anything/qgis/tests/test_core.py::test_run_process_json_normalizes_backend_failure PASSED +QGIS/agent-harness/cli_anything/qgis/tests/test_full_e2e.py::TestRealCLIWorkflows::test_scratch_project_to_pdf PDF artifact: /tmp/pytest-of-wangh68/pytest-10/test_scratch_project_to_pdf0/workflow.pdf +PASSED +QGIS/agent-harness/cli_anything/qgis/tests/test_full_e2e.py::TestRealCLIWorkflows::test_scratch_project_to_png PNG artifact: /tmp/pytest-of-wangh68/pytest-10/test_scratch_project_to_png0/workflow.png +PASSED +QGIS/agent-harness/cli_anything/qgis/tests/test_full_e2e.py::TestRealCLIWorkflows::test_processing_passthrough_buffer PASSED +QGIS/agent-harness/cli_anything/qgis/tests/test_full_e2e.py::TestCLISubprocess::test_help [_resolve_cli] Using sibling command: /home/wangh68/project/cli_anything_g/QGIS/agent-harness/.venv/bin/cli-anything-qgis +PASSED +QGIS/agent-harness/cli_anything/qgis/tests/test_full_e2e.py::TestCLISubprocess::test_process_help_json [_resolve_cli] Using sibling command: /home/wangh68/project/cli_anything_g/QGIS/agent-harness/.venv/bin/cli-anything-qgis +PASSED +QGIS/agent-harness/cli_anything/qgis/tests/test_full_e2e.py::TestCLISubprocess::test_project_new_json [_resolve_cli] Using sibling command: /home/wangh68/project/cli_anything_g/QGIS/agent-harness/.venv/bin/cli-anything-qgis +PASSED +QGIS/agent-harness/cli_anything/qgis/tests/test_full_e2e.py::TestCLISubprocess::test_full_pdf_workflow [_resolve_cli] Using sibling command: /home/wangh68/project/cli_anything_g/QGIS/agent-harness/.venv/bin/cli-anything-qgis +Subprocess PDF artifact: /tmp/pytest-of-wangh68/pytest-10/test_full_pdf_workflow0/subprocess.pdf +PASSED +QGIS/agent-harness/cli_anything/qgis/tests/test_full_e2e.py::TestCLISubprocess::test_full_png_workflow [_resolve_cli] Using sibling command: /home/wangh68/project/cli_anything_g/QGIS/agent-harness/.venv/bin/cli-anything-qgis +Subprocess PNG artifact: /tmp/pytest-of-wangh68/pytest-10/test_full_png_workflow0/subprocess.png +PASSED +QGIS/agent-harness/cli_anything/qgis/tests/test_full_e2e.py::TestCLISubprocess::test_point_only_project_add_map_without_extent [_resolve_cli] Using sibling command: /home/wangh68/project/cli_anything_g/QGIS/agent-harness/.venv/bin/cli-anything-qgis +Subprocess point-only PDF artifact: /tmp/pytest-of-wangh68/pytest-10/test_point_only_project_add_ma0/subprocess_point.pdf +PASSED + +=============================== warnings summary =============================== +cli_anything/qgis/tests/test_core.py::test_layout_create_add_items_and_remove +cli_anything/qgis/tests/test_full_e2e.py::TestRealCLIWorkflows::test_scratch_project_to_pdf +cli_anything/qgis/tests/test_full_e2e.py::TestRealCLIWorkflows::test_scratch_project_to_png +cli_anything/qgis/tests/test_full_e2e.py::TestRealCLIWorkflows::test_processing_passthrough_buffer + /home/wangh68/project/cli_anything_g/QGIS/agent-harness/cli_anything/qgis/core/layouts.py:187: DeprecationWarning: QgsLayoutItemLabel.setFont() is deprecated + label.setFont(font) + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +======================= 22 passed, 4 warnings in 18.95s ======================== +``` diff --git a/QGIS/agent-harness/cli_anything/qgis/tests/test_core.py b/QGIS/agent-harness/cli_anything/qgis/tests/test_core.py new file mode 100644 index 0000000..5ff4970 --- /dev/null +++ b/QGIS/agent-harness/cli_anything/qgis/tests/test_core.py @@ -0,0 +1,259 @@ +"""Core tests for cli-anything-qgis using PyQGIS and focused backend mocks.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path +from unittest import mock + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +_PACKAGE_NAMESPACE_ROOT = Path(__file__).resolve().parents[2] +if str(_PACKAGE_NAMESPACE_ROOT) in sys.path: + sys.path.remove(str(_PACKAGE_NAMESPACE_ROOT)) + +from cli_anything.qgis.core import export as export_mod +from cli_anything.qgis.core import features as features_mod +from cli_anything.qgis.core import layers as layers_mod +from cli_anything.qgis.core import layouts as layouts_mod +from cli_anything.qgis.core import processing as processing_mod +from cli_anything.qgis.core import project as project_mod +from cli_anything.qgis.core.session import Session +from cli_anything.qgis.utils import qgis_backend as backend +from cli_anything.qgis.utils.qgis_backend import QgisBackendError, QgisProcessError + + +@pytest.fixture(autouse=True) +def clean_qgis_project(): + backend.ensure_qgis_app() + project = project_mod.current_project() + project.clear() + project.setFileName("") + yield + project.clear() + project.setFileName("") + + +def _create_polygon_project(tmp_path: Path, name: str = "demo") -> Path: + project_path = tmp_path / f"{name}.qgz" + project_mod.create_project(str(project_path), title=name.title(), crs="EPSG:4326") + layers_mod.create_vector_layer( + "areas", + "polygon", + "EPSG:4326", + ["name:string", "active:bool", "count:int"], + ) + features_mod.add_feature( + "areas", + "POLYGON((0 0,0 5,5 5,5 0,0 0))", + ["name=ZoneA", "active=true", "count=3"], + ) + project_mod.save_project() + return project_path + + +def test_project_create_save_open_and_info(tmp_path: Path): + project_path = tmp_path / "sample.qgz" + + created = project_mod.create_project(str(project_path), title="Sample", crs="EPSG:4326") + assert created["path"] == str(project_path.resolve()) + assert created["title"] == "Sample" + assert created["crs"] == "EPSG:4326" + assert created["layer_count"] == 0 + assert created["layout_count"] == 0 + assert created["datastore_path"] == str((tmp_path / "sample_data.gpkg").resolve()) + + updated = project_mod.set_project_crs("EPSG:3857") + assert updated["crs"] == "EPSG:3857" + + saved = project_mod.save_project() + assert Path(saved["path"]).exists() + + renamed_path = tmp_path / "renamed.qgz" + renamed = project_mod.save_project(str(renamed_path)) + assert renamed["path"] == str(renamed_path.resolve()) + assert Path(renamed["path"]).exists() + + reopened = project_mod.open_project(str(renamed_path)) + assert reopened["path"] == str(renamed_path.resolve()) + assert reopened["crs"] == "EPSG:3857" + assert reopened["title"] == "Sample" + + +def test_default_datastore_path(tmp_path: Path): + project_path = tmp_path / "city.qgz" + expected = tmp_path / "city_data.gpkg" + assert project_mod.default_datastore_path(str(project_path)) == str(expected) + + +def test_parse_field_and_param_specs(): + fields = layers_mod.parse_field_specs(["name:string", "score:int", "active:bool"]) + assert [field["name"] for field in fields] == ["name", "score", "active"] + assert [field["type"] for field in fields] == ["string", "integer", "bool"] + + params = processing_mod.parse_param_specs(["INPUT=areas", "DISTANCE=10"]) + assert params == ["INPUT=areas", "DISTANCE=10"] + + with pytest.raises(QgisBackendError): + layers_mod.parse_field_specs(["name:string", "name:int"]) + + with pytest.raises(QgisBackendError): + processing_mod.parse_param_specs(["NOT_A_PARAM"]) + + +def test_layer_create_list_info_and_remove(tmp_path: Path): + project_mod.create_project(str(tmp_path / "layers.qgz"), title="Layers", crs="EPSG:4326") + + created = layers_mod.create_vector_layer( + "places", + "point", + "EPSG:4326", + ["name:string", "score:int"], + ) + assert created["name"] == "places" + assert created["provider"] == "ogr" + assert created["type"] == "vector" + assert {"name", "score"}.issubset({field["name"] for field in created["fields"]}) + + listing = layers_mod.list_layers() + assert listing["count"] == 1 + assert listing["layers"][0]["name"] == "places" + + info = layers_mod.layer_info("places") + assert info["id"] == created["id"] + assert info["source"].endswith("layers_data.gpkg|layername=places") + + removed = layers_mod.remove_layer("places") + assert removed["name"] == "places" + assert layers_mod.list_layers()["count"] == 0 + + +def test_feature_add_and_list(tmp_path: Path): + project_mod.create_project(str(tmp_path / "features.qgz"), title="Features", crs="EPSG:4326") + layers_mod.create_vector_layer( + "points", + "point", + "EPSG:4326", + ["name:string", "count:int", "rating:double", "active:bool"], + ) + + added = features_mod.add_feature( + "points", + "POINT(1 2)", + ["name=HQ", "count=7", "rating=2.5", "active=true"], + ) + attrs = added["feature"]["attributes"] + assert attrs["name"] == "HQ" + assert attrs["count"] == 7 + assert float(attrs["rating"]) == pytest.approx(2.5) + assert bool(attrs["active"]) is True + + listing = features_mod.list_features("points", limit=1) + assert listing["feature_count"] == 1 + assert len(listing["features"]) == 1 + assert listing["features"][0]["geometry_wkt"].startswith("Point") + + +def test_feature_add_rejects_invalid_boolean(tmp_path: Path): + project_mod.create_project(str(tmp_path / "invalid_bool.qgz"), title="InvalidBool", crs="EPSG:4326") + layers_mod.create_vector_layer("points", "point", "EPSG:4326", ["active:bool"]) + + with pytest.raises(QgisBackendError): + features_mod.add_feature("points", "POINT(0 0)", ["active=maybe"]) + + +def test_layout_create_add_items_and_remove(tmp_path: Path): + _create_polygon_project(tmp_path, name="layout_demo") + + created = layouts_mod.create_layout("Main", page_size="A4", orientation="portrait") + assert created["name"] == "Main" + + with_map = layouts_mod.add_map_item("Main", 10, 20, 180, 120) + assert any(item["type"] == "QgsLayoutItemMap" for item in with_map["items"]) + + with_label = layouts_mod.add_label_item("Main", "Demo map", 10, 8, 80, 10, font_size=16) + assert any(item["type"] == "QgsLayoutItemLabel" for item in with_label["items"]) + + listing = layouts_mod.list_layouts() + assert listing["count"] == 1 + assert listing["layouts"][0]["name"] == "Main" + + removed = layouts_mod.remove_layout("Main") + assert removed["name"] == "Main" + assert layouts_mod.list_layouts()["count"] == 0 + + +def test_layout_add_map_accepts_point_only_project(tmp_path: Path): + project_mod.create_project(str(tmp_path / "point_layout.qgz"), title="PointLayout", crs="EPSG:4326") + layers_mod.create_vector_layer("points", "point", "EPSG:4326", ["name:string"]) + features_mod.add_feature("points", "POINT(1 2)", ["name=HQ"]) + + layouts_mod.create_layout("Main", page_size="A4", orientation="portrait") + with_map = layouts_mod.add_map_item("Main", 10, 20, 180, 120) + + assert any(item["type"] == "QgsLayoutItemMap" for item in with_map["items"]) + + +def test_export_presets_describe_supported_formats(): + presets = export_mod.export_presets() + formats = {item["name"]: item["algorithm"] for item in presets["formats"]} + assert formats == { + "pdf": "native:printlayouttopdf", + "image": "native:printlayouttoimage", + } + + +def test_session_save_load_and_status(tmp_path: Path): + session_path = tmp_path / "session.json" + session = Session(str(session_path)) + session.set_project_path("/tmp/demo.qgz") + session.record("project info", {"project": "/tmp/demo.qgz"}, {"path": "/tmp/demo.qgz"}) + + reloaded = Session(str(session_path)) + assert reloaded.current_project_path == "/tmp/demo.qgz" + assert reloaded.history_count == 1 + assert reloaded.history(limit=1)[0]["command"] == "project info" + + status = reloaded.status(modified=True) + assert status == { + "current_project_path": "/tmp/demo.qgz", + "project_name": "demo.qgz", + "modified": True, + "history_count": 1, + } + + +def test_project_path_argument_normalizes(tmp_path: Path): + project_path = tmp_path / "demo.qgz" + expected = f"--PROJECT_PATH={project_path.resolve()}" + assert backend.project_path_argument(project_path) == expected + assert backend.project_path_argument(None) is None + + +def test_find_qgis_process_missing_raises_clear_error(): + with mock.patch("cli_anything.qgis.utils.qgis_backend.shutil.which", return_value=None): + with pytest.raises(QgisBackendError, match="qgis_process is not installed"): + backend.find_qgis_process() + + +def test_run_process_json_normalizes_backend_failure(): + payload = {"log": [{"message": "buffer failed"}]} + completed = subprocess.CompletedProcess( + args=["/usr/bin/qgis_process", "--json", "run", "native:buffer"], + returncode=1, + stdout=json.dumps(payload), + stderr="", + ) + + with mock.patch("cli_anything.qgis.utils.qgis_backend.find_qgis_process", return_value="/usr/bin/qgis_process"): + with mock.patch("cli_anything.qgis.utils.qgis_backend.subprocess.run", return_value=completed): + with pytest.raises(QgisProcessError, match="buffer failed") as exc_info: + backend.run_process_json(["run", "native:buffer"]) + + assert exc_info.value.returncode == 1 + assert exc_info.value.payload == payload diff --git a/QGIS/agent-harness/cli_anything/qgis/tests/test_full_e2e.py b/QGIS/agent-harness/cli_anything/qgis/tests/test_full_e2e.py new file mode 100644 index 0000000..10d6ce0 --- /dev/null +++ b/QGIS/agent-harness/cli_anything/qgis/tests/test_full_e2e.py @@ -0,0 +1,562 @@ +"""End-to-end and subprocess tests for cli-anything-qgis.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest +from click.testing import CliRunner + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +_PACKAGE_NAMESPACE_ROOT = Path(__file__).resolve().parents[2] +if str(_PACKAGE_NAMESPACE_ROOT) in sys.path: + sys.path.remove(str(_PACKAGE_NAMESPACE_ROOT)) + +from cli_anything.qgis import qgis_cli +from cli_anything.qgis.core import project as project_mod +from cli_anything.qgis.qgis_cli import cli +from cli_anything.qgis.utils import qgis_backend as backend + + +PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" + + +def _resolve_cli(name: str) -> list[str]: + """Resolve the CLI entry-point for subprocess tests. + + Prefers an installed command on PATH and falls back to ``python -m`` + unless ``CLI_ANYTHING_FORCE_INSTALLED=1`` is set. + """ + force = os.environ.get("CLI_ANYTHING_FORCE_INSTALLED", "").strip() == "1" + sibling = Path(sys.executable).parent / name + if sibling.exists(): + print(f"[_resolve_cli] Using sibling command: {sibling}") + return [str(sibling)] + path = shutil.which(name) + if path: + print(f"[_resolve_cli] Using installed command: {path}") + return [path] + if force: + raise RuntimeError(f"{name} not found in PATH. Install with: python3 -m pip install -e .") + module = "cli_anything.qgis.qgis_cli" + print(f"[_resolve_cli] Falling back to: {sys.executable} -m {module}") + return [sys.executable, "-m", module] + + +@pytest.fixture(autouse=True) +def clean_qgis_state(monkeypatch, tmp_path): + home_dir = tmp_path / "home" + home_dir.mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("HOME", str(home_dir)) + monkeypatch.setenv("QT_QPA_PLATFORM", os.environ.get("QT_QPA_PLATFORM", "offscreen")) + + backend.ensure_qgis_app() + project = project_mod.current_project() + project.clear() + project.setFileName("") + qgis_cli._session = None + qgis_cli._json_output = False + qgis_cli._repl_mode = False + + yield + + project.clear() + project.setFileName("") + qgis_cli._session = None + qgis_cli._json_output = False + qgis_cli._repl_mode = False + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +def _parse_json_output(raw: str) -> dict: + return json.loads(raw) + + +def _invoke_json(runner: CliRunner, args: list[str]) -> dict: + result = runner.invoke(cli, ["--json", *args]) + assert result.exit_code == 0, result.output + return _parse_json_output(result.output) + + +def _subprocess_json(command: list[str], args: list[str], env: dict[str, str]) -> dict: + completed = subprocess.run( + [*command, "--json", *args], + capture_output=True, + text=True, + check=False, + env=env, + ) + assert completed.returncode == 0, completed.stderr or completed.stdout + return json.loads(completed.stdout) + + +def _build_cli_project(runner: CliRunner, tmp_path: Path, stem: str) -> dict[str, str]: + project_path = tmp_path / f"{stem}.qgz" + + _invoke_json(runner, ["project", "new", "-o", str(project_path), "--title", stem]) + layer = _invoke_json( + runner, + [ + "--project", + str(project_path), + "layer", + "create-vector", + "--name", + "areas", + "--geometry", + "polygon", + "--field", + "name:string", + "--field", + "score:int", + ], + ) + _invoke_json( + runner, + [ + "--project", + str(project_path), + "feature", + "add", + "--layer", + "areas", + "--wkt", + "POLYGON((0 0,0 5,5 5,5 0,0 0))", + "--attr", + "name=ZoneA", + "--attr", + "score=5", + ], + ) + _invoke_json(runner, ["--project", str(project_path), "layout", "create", "--name", "Main"]) + _invoke_json( + runner, + [ + "--project", + str(project_path), + "layout", + "add-map", + "--layout", + "Main", + "--x", + "10", + "--y", + "20", + "--width", + "180", + "--height", + "120", + ], + ) + _invoke_json( + runner, + [ + "--project", + str(project_path), + "layout", + "add-label", + "--layout", + "Main", + "--text", + "Demo map", + "--x", + "10", + "--y", + "8", + "--width", + "100", + "--height", + "10", + ], + ) + + return { + "project_path": str(project_path), + "layer_source": layer["source"], + } + + +def _build_subprocess_project(command: list[str], tmp_path: Path, stem: str, env: dict[str, str]) -> dict[str, str]: + project_path = tmp_path / f"{stem}.qgz" + + _subprocess_json(command, ["project", "new", "-o", str(project_path), "--title", stem], env) + layer = _subprocess_json( + command, + [ + "--project", + str(project_path), + "layer", + "create-vector", + "--name", + "areas", + "--geometry", + "polygon", + "--field", + "name:string", + "--field", + "score:int", + ], + env, + ) + _subprocess_json( + command, + [ + "--project", + str(project_path), + "feature", + "add", + "--layer", + "areas", + "--wkt", + "POLYGON((0 0,0 5,5 5,5 0,0 0))", + "--attr", + "name=ZoneA", + "--attr", + "score=5", + ], + env, + ) + _subprocess_json(command, ["--project", str(project_path), "layout", "create", "--name", "Main"], env) + _subprocess_json( + command, + [ + "--project", + str(project_path), + "layout", + "add-map", + "--layout", + "Main", + "--x", + "10", + "--y", + "20", + "--width", + "180", + "--height", + "120", + ], + env, + ) + _subprocess_json( + command, + [ + "--project", + str(project_path), + "layout", + "add-label", + "--layout", + "Main", + "--text", + "Demo map", + "--x", + "10", + "--y", + "8", + "--width", + "100", + "--height", + "10", + ], + env, + ) + + return { + "project_path": str(project_path), + "layer_source": layer["source"], + } + + +def _build_subprocess_point_project( + command: list[str], tmp_path: Path, stem: str, env: dict[str, str] +) -> dict[str, str]: + project_path = tmp_path / f"{stem}.qgz" + + _subprocess_json(command, ["project", "new", "-o", str(project_path), "--title", stem], env) + layer = _subprocess_json( + command, + [ + "--project", + str(project_path), + "layer", + "create-vector", + "--name", + "places", + "--geometry", + "point", + "--field", + "name:string", + "--field", + "score:int", + ], + env, + ) + _subprocess_json( + command, + [ + "--project", + str(project_path), + "feature", + "add", + "--layer", + "places", + "--wkt", + "POINT(116.397 39.907)", + "--attr", + "name=Beijing", + "--attr", + "score=5", + ], + env, + ) + _subprocess_json(command, ["--project", str(project_path), "layout", "create", "--name", "Main"], env) + + return { + "project_path": str(project_path), + "layer_source": layer["source"], + } + + +class TestRealCLIWorkflows: + def test_scratch_project_to_pdf(self, runner: CliRunner, tmp_path: Path): + build = _build_cli_project(runner, tmp_path, "pdf_workflow") + pdf_path = tmp_path / "workflow.pdf" + + exported = _invoke_json( + runner, + [ + "--project", + build["project_path"], + "export", + "pdf", + str(pdf_path), + "--layout", + "Main", + "--overwrite", + ], + ) + + output_path = Path(exported["output"]) + assert output_path.exists() + assert output_path.stat().st_size > 0 + assert output_path.read_bytes()[:5] == b"%PDF-" + print(f"PDF artifact: {output_path}") + + def test_scratch_project_to_png(self, runner: CliRunner, tmp_path: Path): + from qgis.PyQt.QtGui import QImage + + build = _build_cli_project(runner, tmp_path, "png_workflow") + png_path = tmp_path / "workflow.png" + + exported = _invoke_json( + runner, + [ + "--project", + build["project_path"], + "export", + "image", + str(png_path), + "--layout", + "Main", + "--overwrite", + ], + ) + + output_path = Path(exported["output"]) + assert output_path.exists() + assert output_path.read_bytes()[:8] == PNG_SIGNATURE + + image = QImage(str(output_path)) + assert not image.isNull() + assert image.width() > 0 + assert image.height() > 0 + print(f"PNG artifact: {output_path}") + + def test_processing_passthrough_buffer(self, runner: CliRunner, tmp_path: Path): + from qgis.core import QgsVectorLayer + + build = _build_cli_project(runner, tmp_path, "buffer_workflow") + output_path = tmp_path / "buffer.geojson" + + data = _invoke_json( + runner, + [ + "--project", + build["project_path"], + "process", + "run", + "native:buffer", + "--param", + f"INPUT={build['layer_source']}", + "--param", + "DISTANCE=1", + "--param", + "SEGMENTS=8", + "--param", + "END_CAP_STYLE=0", + "--param", + "JOIN_STYLE=0", + "--param", + "MITER_LIMIT=2", + "--param", + "DISSOLVE=false", + "--param", + f"OUTPUT={output_path}", + ], + ) + + result_path = Path(data["results"]["OUTPUT"]) + assert result_path.exists() + + backend.ensure_qgis_app() + layer = QgsVectorLayer(str(result_path), "buffer", "ogr") + assert layer.isValid() + assert int(layer.featureCount()) > 0 + + +class TestCLISubprocess: + def test_help(self, tmp_path: Path): + command = _resolve_cli("cli-anything-qgis") + env = os.environ.copy() + env["HOME"] = str(tmp_path / "home") + env.setdefault("QT_QPA_PLATFORM", "offscreen") + + completed = subprocess.run([*command, "--help"], capture_output=True, text=True, check=False, env=env) + assert completed.returncode == 0 + assert "QGIS CLI" in completed.stdout + + def test_process_help_json(self, tmp_path: Path): + command = _resolve_cli("cli-anything-qgis") + env = os.environ.copy() + env["HOME"] = str(tmp_path / "home") + env.setdefault("QT_QPA_PLATFORM", "offscreen") + + payload = _subprocess_json(command, ["process", "help", "native:printlayouttopdf"], env) + assert payload["algorithm"]["id"] == "native:printlayouttopdf" + + def test_project_new_json(self, tmp_path: Path): + command = _resolve_cli("cli-anything-qgis") + env = os.environ.copy() + env["HOME"] = str(tmp_path / "home") + env.setdefault("QT_QPA_PLATFORM", "offscreen") + + project_path = tmp_path / "subprocess.qgz" + payload = _subprocess_json(command, ["project", "new", "-o", str(project_path), "--title", "Subprocess"], env) + assert payload["path"] == str(project_path.resolve()) + assert Path(payload["path"]).exists() + + def test_full_pdf_workflow(self, tmp_path: Path): + command = _resolve_cli("cli-anything-qgis") + env = os.environ.copy() + env["HOME"] = str(tmp_path / "home") + env.setdefault("QT_QPA_PLATFORM", "offscreen") + + build = _build_subprocess_project(command, tmp_path, "subprocess_pdf", env) + pdf_path = tmp_path / "subprocess.pdf" + exported = _subprocess_json( + command, + [ + "--project", + build["project_path"], + "export", + "pdf", + str(pdf_path), + "--layout", + "Main", + "--overwrite", + ], + env, + ) + + output_path = Path(exported["output"]) + assert output_path.exists() + assert output_path.stat().st_size > 0 + assert output_path.read_bytes()[:5] == b"%PDF-" + print(f"Subprocess PDF artifact: {output_path}") + + def test_full_png_workflow(self, tmp_path: Path): + command = _resolve_cli("cli-anything-qgis") + env = os.environ.copy() + env["HOME"] = str(tmp_path / "home") + env.setdefault("QT_QPA_PLATFORM", "offscreen") + + build = _build_subprocess_project(command, tmp_path, "subprocess_png", env) + png_path = tmp_path / "subprocess.png" + exported = _subprocess_json( + command, + [ + "--project", + build["project_path"], + "export", + "image", + str(png_path), + "--layout", + "Main", + "--overwrite", + ], + env, + ) + + output_path = Path(exported["output"]) + assert output_path.exists() + assert output_path.read_bytes()[:8] == PNG_SIGNATURE + print(f"Subprocess PNG artifact: {output_path}") + + def test_point_only_project_add_map_without_extent(self, tmp_path: Path): + command = _resolve_cli("cli-anything-qgis") + env = os.environ.copy() + env["HOME"] = str(tmp_path / "home") + env.setdefault("QT_QPA_PLATFORM", "offscreen") + + build = _build_subprocess_point_project(command, tmp_path, "subprocess_point", env) + add_map = _subprocess_json( + command, + [ + "--project", + build["project_path"], + "layout", + "add-map", + "--layout", + "Main", + "--x", + "10", + "--y", + "20", + "--width", + "180", + "--height", + "120", + ], + env, + ) + assert any(item["type"] == "QgsLayoutItemMap" for item in add_map["items"]) + + pdf_path = tmp_path / "subprocess_point.pdf" + exported = _subprocess_json( + command, + [ + "--project", + build["project_path"], + "export", + "pdf", + str(pdf_path), + "--layout", + "Main", + "--overwrite", + ], + env, + ) + + output_path = Path(exported["output"]) + assert output_path.exists() + assert output_path.read_bytes()[:5] == b"%PDF-" + print(f"Subprocess point-only PDF artifact: {output_path}") diff --git a/QGIS/agent-harness/cli_anything/qgis/utils/__init__.py b/QGIS/agent-harness/cli_anything/qgis/utils/__init__.py new file mode 100644 index 0000000..42d4651 --- /dev/null +++ b/QGIS/agent-harness/cli_anything/qgis/utils/__init__.py @@ -0,0 +1 @@ +"""Utility helpers for cli-anything-qgis.""" diff --git a/QGIS/agent-harness/cli_anything/qgis/utils/qgis_backend.py b/QGIS/agent-harness/cli_anything/qgis/utils/qgis_backend.py new file mode 100644 index 0000000..c44ed67 --- /dev/null +++ b/QGIS/agent-harness/cli_anything/qgis/utils/qgis_backend.py @@ -0,0 +1,247 @@ +"""Low-level backend helpers for PyQGIS and qgis_process.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Any + +_QGIS_APP = None + + +class QgisBackendError(RuntimeError): + """Base error raised by the QGIS backend wrapper.""" + + +class QgisProcessError(QgisBackendError): + """Raised when qgis_process fails.""" + + def __init__( + self, + message: str, + *, + command: list[str], + returncode: int, + stdout: str, + stderr: str, + payload: dict[str, Any] | None = None, + ) -> None: + super().__init__(message) + self.command = command + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + self.payload = payload + + +def _normalize_path(path: str | os.PathLike[str]) -> str: + return str(Path(path).expanduser().resolve()) + + +def _detect_qgis_prefix() -> str: + prefix = os.environ.get("QGIS_PREFIX_PATH") + if prefix: + return prefix + + for binary in ("qgis_process", "qgis"): + found = shutil.which(binary) + if found: + return str(Path(found).resolve().parent.parent) + + return "/usr" + + +def find_qgis_process() -> str: + """Return the qgis_process executable path or raise a clear error.""" + path = shutil.which("qgis_process") + if path: + return path + raise QgisBackendError( + "qgis_process is not installed or not available in PATH. Install QGIS and ensure " + "the qgis_process command is available. Example: apt install qgis" + ) + + +def _is_shadow_qgis_module(module: Any) -> bool: + shadow_package = Path(__file__).resolve().parents[1] + + module_file = getattr(module, "__file__", "") or "" + if module_file: + try: + if Path(module_file).resolve().is_relative_to(shadow_package): + return True + except OSError: + pass + + module_paths = getattr(module, "__path__", None) or [] + for item in module_paths: + try: + if Path(item).resolve().is_relative_to(shadow_package): + return True + except OSError: + continue + + return False + + + +def _import_qgs_application(): + shadow_root = str(Path(__file__).resolve().parents[2]) + + for name, module in list(sys.modules.items()): + if (name == "qgis" or name.startswith("qgis.")) and _is_shadow_qgis_module(module): + sys.modules.pop(name, None) + + removed_shadow_root = False + if shadow_root in sys.path: + sys.path.remove(shadow_root) + removed_shadow_root = True + + try: + from qgis.core import QgsApplication + except ImportError as exc: + raise QgisBackendError( + "PyQGIS is not importable in this Python environment. Install QGIS with Python " + "bindings and use a Python interpreter that can import qgis.core." + ) from exc + finally: + if removed_shadow_root: + sys.path.insert(0, shadow_root) + + return QgsApplication + + + +def ensure_qgis_app(): + """Initialize PyQGIS once for this Python process.""" + global _QGIS_APP + + if _QGIS_APP is not None: + return _QGIS_APP + + QgsApplication = _import_qgs_application() + QgsApplication.setPrefixPath(_detect_qgis_prefix(), True) + _QGIS_APP = QgsApplication([], False) + _QGIS_APP.initQgis() + return _QGIS_APP + + +def project_path_argument(project_path: str | None) -> str | None: + """Normalize project paths for qgis_process invocations.""" + if not project_path: + return None + return f"--PROJECT_PATH={_normalize_path(project_path)}" + + +def _extract_payload_message(payload: dict[str, Any] | None) -> str | None: + if not payload: + return None + + log_entries = payload.get("log") + if isinstance(log_entries, list): + parts: list[str] = [] + for entry in log_entries: + if isinstance(entry, dict): + message = entry.get("message") or entry.get("text") + if message: + parts.append(str(message)) + elif entry: + parts.append(str(entry)) + if parts: + return " | ".join(parts[-3:]) + + results = payload.get("results") + if isinstance(results, dict) and "error" in results: + return str(results["error"]) + + return None + + +def run_process_json( + arguments: list[str], + *, + project_path: str | None = None, + parameters: list[str] | None = None, +) -> dict[str, Any]: + """Run qgis_process in JSON mode and return parsed output.""" + command = [find_qgis_process(), "--json", *arguments] + project_arg = project_path_argument(project_path) + if project_arg: + command.append(project_arg) + if parameters: + command.append("--") + command.extend(parameters) + + completed = subprocess.run( + command, + capture_output=True, + text=True, + check=False, + ) + + stdout = completed.stdout.strip() + stderr = completed.stderr.strip() + payload: dict[str, Any] | None = None + + if stdout: + try: + parsed = json.loads(stdout) + if isinstance(parsed, dict): + payload = parsed + else: + payload = {"data": parsed} + except json.JSONDecodeError: + payload = None + + if completed.returncode != 0: + message = _extract_payload_message(payload) or stderr or stdout or ( + f"qgis_process exited with status {completed.returncode}" + ) + raise QgisProcessError( + message, + command=command, + returncode=completed.returncode, + stdout=stdout, + stderr=stderr, + payload=payload, + ) + + if payload is None: + raise QgisProcessError( + "qgis_process returned non-JSON output in --json mode", + command=command, + returncode=completed.returncode, + stdout=stdout, + stderr=stderr, + payload=None, + ) + + return payload + + +def list_algorithms() -> dict[str, Any]: + """Return the raw qgis_process list payload.""" + return run_process_json(["list"]) + + +def help_algorithm(algorithm_id: str) -> dict[str, Any]: + """Return the raw qgis_process help payload for an algorithm.""" + return run_process_json(["help", algorithm_id]) + + +def run_algorithm( + algorithm_id: str, + *, + parameters: list[str] | None = None, + project_path: str | None = None, +) -> dict[str, Any]: + """Run a qgis_process algorithm and return the raw JSON payload.""" + return run_process_json( + ["run", algorithm_id], + project_path=project_path, + parameters=parameters, + ) diff --git a/QGIS/agent-harness/cli_anything/qgis/utils/repl_skin.py b/QGIS/agent-harness/cli_anything/qgis/utils/repl_skin.py new file mode 100644 index 0000000..8a043a9 --- /dev/null +++ b/QGIS/agent-harness/cli_anything/qgis/utils/repl_skin.py @@ -0,0 +1,500 @@ +"""cli-anything REPL Skin — Unified terminal interface for all CLI harnesses. + +Copy this file into your CLI package at: + cli_anything//utils/repl_skin.py + +Usage: + from cli_anything..utils.repl_skin import ReplSkin + + skin = ReplSkin("shotcut", version="1.0.0") + skin.print_banner() + prompt_text = skin.prompt(project_name="my_video.mlt", modified=True) + skin.success("Project saved") + skin.error("File not found") + skin.warning("Unsaved changes") + skin.info("Processing 24 clips...") + skin.status("Track 1", "3 clips, 00:02:30") + skin.table(headers, rows) + skin.print_goodbye() +""" + +import os +import sys + +# ── ANSI color codes (no external deps for core styling) ────────────── + +_RESET = "\033[0m" +_BOLD = "\033[1m" +_DIM = "\033[2m" +_ITALIC = "\033[3m" +_UNDERLINE = "\033[4m" + +# Brand colors +_CYAN = "\033[38;5;80m" # cli-anything brand cyan +_CYAN_BG = "\033[48;5;80m" +_WHITE = "\033[97m" +_GRAY = "\033[38;5;245m" +_DARK_GRAY = "\033[38;5;240m" +_LIGHT_GRAY = "\033[38;5;250m" + +# Software accent colors — each software gets a unique accent +_ACCENT_COLORS = { + "gimp": "\033[38;5;214m", # warm orange + "blender": "\033[38;5;208m", # deep orange + "inkscape": "\033[38;5;39m", # bright blue + "audacity": "\033[38;5;33m", # navy blue + "libreoffice": "\033[38;5;40m", # green + "obs_studio": "\033[38;5;55m", # purple + "kdenlive": "\033[38;5;69m", # slate blue + "shotcut": "\033[38;5;35m", # teal green + "qgis": "\033[38;5;34m", # qgis green +} +_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue + +# Status colors +_GREEN = "\033[38;5;78m" +_YELLOW = "\033[38;5;220m" +_RED = "\033[38;5;196m" +_BLUE = "\033[38;5;75m" +_MAGENTA = "\033[38;5;176m" + +# ── Brand icon ──────────────────────────────────────────────────────── + +# The cli-anything icon: a small colored diamond/chevron mark +_ICON = f"{_CYAN}{_BOLD}◆{_RESET}" +_ICON_SMALL = f"{_CYAN}▸{_RESET}" + +# ── Box drawing characters ──────────────────────────────────────────── + +_H_LINE = "─" +_V_LINE = "│" +_TL = "╭" +_TR = "╮" +_BL = "╰" +_BR = "╯" +_T_DOWN = "┬" +_T_UP = "┴" +_T_RIGHT = "├" +_T_LEFT = "┤" +_CROSS = "┼" + + +def _strip_ansi(text: str) -> str: + """Remove ANSI escape codes for length calculation.""" + import re + return re.sub(r"\033\[[^m]*m", "", text) + + +def _visible_len(text: str) -> int: + """Get visible length of text (excluding ANSI codes).""" + return len(_strip_ansi(text)) + + +class ReplSkin: + """Unified REPL skin for cli-anything CLIs. + + Provides consistent branding, prompts, and message formatting + across all CLI harnesses built with the cli-anything methodology. + """ + + def __init__(self, software: str, version: str = "1.0.0", + history_file: str | None = None): + """Initialize the REPL skin. + + Args: + software: Software name (e.g., "gimp", "shotcut", "blender"). + version: CLI version string. + history_file: Path for persistent command history. + Defaults to ~/.cli-anything-/history + """ + self.software = software.lower().replace("-", "_") + self.display_name = software.replace("_", " ").title() + self.version = version + self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT) + + # History file + if history_file is None: + from pathlib import Path + hist_dir = Path.home() / f".cli-anything-{self.software}" + hist_dir.mkdir(parents=True, exist_ok=True) + self.history_file = str(hist_dir / "history") + else: + self.history_file = history_file + + # Detect terminal capabilities + self._color = self._detect_color_support() + + def _detect_color_support(self) -> bool: + """Check if terminal supports color.""" + if os.environ.get("NO_COLOR"): + return False + if os.environ.get("CLI_ANYTHING_NO_COLOR"): + return False + if not hasattr(sys.stdout, "isatty"): + return False + return sys.stdout.isatty() + + def _c(self, code: str, text: str) -> str: + """Apply color code if colors are supported.""" + if not self._color: + return text + return f"{code}{text}{_RESET}" + + # ── Banner ──────────────────────────────────────────────────────── + + def print_banner(self): + """Print the startup banner with branding.""" + inner = 54 + + def _box_line(content: str) -> str: + """Wrap content in box drawing, padding to inner width.""" + pad = inner - _visible_len(content) + vl = self._c(_DARK_GRAY, _V_LINE) + return f"{vl}{content}{' ' * max(0, pad)}{vl}" + + top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}") + bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}") + + # Title: ◆ cli-anything · Shotcut + icon = self._c(_CYAN + _BOLD, "◆") + brand = self._c(_CYAN + _BOLD, "cli-anything") + dot = self._c(_DARK_GRAY, "·") + name = self._c(self.accent + _BOLD, self.display_name) + title = f" {icon} {brand} {dot} {name}" + + ver = f" {self._c(_DARK_GRAY, f' v{self.version}')}" + tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}" + empty = "" + + print(top) + print(_box_line(title)) + print(_box_line(ver)) + print(_box_line(empty)) + print(_box_line(tip)) + print(bot) + print() + + # ── Prompt ──────────────────────────────────────────────────────── + + def prompt(self, project_name: str = "", modified: bool = False, + context: str = "") -> str: + """Build a styled prompt string for prompt_toolkit or input(). + + Args: + project_name: Current project name (empty if none open). + modified: Whether the project has unsaved changes. + context: Optional extra context to show in prompt. + + Returns: + Formatted prompt string. + """ + parts = [] + + # Icon + if self._color: + parts.append(f"{_CYAN}◆{_RESET} ") + else: + parts.append("> ") + + # Software name + parts.append(self._c(self.accent + _BOLD, self.software)) + + # Project context + if project_name or context: + ctx = context or project_name + mod = "*" if modified else "" + parts.append(f" {self._c(_DARK_GRAY, '[')}") + parts.append(self._c(_LIGHT_GRAY, f"{ctx}{mod}")) + parts.append(self._c(_DARK_GRAY, ']')) + + parts.append(self._c(_GRAY, " ❯ ")) + + return "".join(parts) + + def prompt_tokens(self, project_name: str = "", modified: bool = False, + context: str = ""): + """Build prompt_toolkit formatted text tokens for the prompt. + + Use with prompt_toolkit's FormattedText for proper ANSI handling. + + Returns: + list of (style, text) tuples for prompt_toolkit. + """ + accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff") + tokens = [] + + tokens.append(("class:icon", "◆ ")) + tokens.append(("class:software", self.software)) + + if project_name or context: + ctx = context or project_name + mod = "*" if modified else "" + tokens.append(("class:bracket", " [")) + tokens.append(("class:context", f"{ctx}{mod}")) + tokens.append(("class:bracket", "]")) + + tokens.append(("class:arrow", " ❯ ")) + + return tokens + + def get_prompt_style(self): + """Get a prompt_toolkit Style object matching the skin. + + Returns: + prompt_toolkit.styles.Style + """ + try: + from prompt_toolkit.styles import Style + except ImportError: + return None + + accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff") + + return Style.from_dict({ + "icon": "#5fdfdf bold", # cyan brand color + "software": f"{accent_hex} bold", + "bracket": "#585858", + "context": "#bcbcbc", + "arrow": "#808080", + # Completion menu + "completion-menu.completion": "bg:#303030 #bcbcbc", + "completion-menu.completion.current": f"bg:{accent_hex} #000000", + "completion-menu.meta.completion": "bg:#303030 #808080", + "completion-menu.meta.completion.current": f"bg:{accent_hex} #000000", + # Auto-suggest + "auto-suggest": "#585858", + # Bottom toolbar + "bottom-toolbar": "bg:#1c1c1c #808080", + "bottom-toolbar.text": "#808080", + }) + + # ── Messages ────────────────────────────────────────────────────── + + def success(self, message: str): + """Print a success message with green checkmark.""" + icon = self._c(_GREEN + _BOLD, "✓") + print(f" {icon} {self._c(_GREEN, message)}") + + def error(self, message: str): + """Print an error message with red cross.""" + icon = self._c(_RED + _BOLD, "✗") + print(f" {icon} {self._c(_RED, message)}", file=sys.stderr) + + def warning(self, message: str): + """Print a warning message with yellow triangle.""" + icon = self._c(_YELLOW + _BOLD, "⚠") + print(f" {icon} {self._c(_YELLOW, message)}") + + def info(self, message: str): + """Print an info message with blue dot.""" + icon = self._c(_BLUE, "●") + print(f" {icon} {self._c(_LIGHT_GRAY, message)}") + + def hint(self, message: str): + """Print a subtle hint message.""" + print(f" {self._c(_DARK_GRAY, message)}") + + def section(self, title: str): + """Print a section header.""" + print() + print(f" {self._c(self.accent + _BOLD, title)}") + print(f" {self._c(_DARK_GRAY, _H_LINE * len(title))}") + + # ── Status display ──────────────────────────────────────────────── + + def status(self, label: str, value: str): + """Print a key-value status line.""" + lbl = self._c(_GRAY, f" {label}:") + val = self._c(_WHITE, f" {value}") + print(f"{lbl}{val}") + + def status_block(self, items: dict[str, str], title: str = ""): + """Print a block of status key-value pairs. + + Args: + items: Dict of label -> value pairs. + title: Optional title for the block. + """ + if title: + self.section(title) + + max_key = max(len(k) for k in items) if items else 0 + for label, value in items.items(): + lbl = self._c(_GRAY, f" {label:<{max_key}}") + val = self._c(_WHITE, f" {value}") + print(f"{lbl}{val}") + + def progress(self, current: int, total: int, label: str = ""): + """Print a simple progress indicator. + + Args: + current: Current step number. + total: Total number of steps. + label: Optional label for the progress. + """ + pct = int(current / total * 100) if total > 0 else 0 + bar_width = 20 + filled = int(bar_width * current / total) if total > 0 else 0 + bar = "█" * filled + "░" * (bar_width - filled) + text = f" {self._c(_CYAN, bar)} {self._c(_GRAY, f'{pct:3d}%')}" + if label: + text += f" {self._c(_LIGHT_GRAY, label)}" + print(text) + + # ── Table display ───────────────────────────────────────────────── + + def table(self, headers: list[str], rows: list[list[str]], + max_col_width: int = 40): + """Print a formatted table with box-drawing characters. + + Args: + headers: Column header strings. + rows: List of rows, each a list of cell strings. + max_col_width: Maximum column width before truncation. + """ + if not headers: + return + + # Calculate column widths + col_widths = [min(len(h), max_col_width) for h in headers] + for row in rows: + for i, cell in enumerate(row): + if i < len(col_widths): + col_widths[i] = min( + max(col_widths[i], len(str(cell))), max_col_width + ) + + def pad(text: str, width: int) -> str: + t = str(text)[:width] + return t + " " * (width - len(t)) + + # Header + header_cells = [ + self._c(_CYAN + _BOLD, pad(h, col_widths[i])) + for i, h in enumerate(headers) + ] + sep = self._c(_DARK_GRAY, f" {_V_LINE} ") + header_line = f" {sep.join(header_cells)}" + print(header_line) + + # Separator + sep_parts = [self._c(_DARK_GRAY, _H_LINE * w) for w in col_widths] + sep_line = self._c(_DARK_GRAY, f" {'───'.join([_H_LINE * w for w in col_widths])}") + print(sep_line) + + # Rows + for row in rows: + cells = [] + for i, cell in enumerate(row): + if i < len(col_widths): + cells.append(self._c(_LIGHT_GRAY, pad(str(cell), col_widths[i]))) + row_sep = self._c(_DARK_GRAY, f" {_V_LINE} ") + print(f" {row_sep.join(cells)}") + + # ── Help display ────────────────────────────────────────────────── + + def help(self, commands: dict[str, str]): + """Print a formatted help listing. + + Args: + commands: Dict of command -> description pairs. + """ + self.section("Commands") + max_cmd = max(len(c) for c in commands) if commands else 0 + for cmd, desc in commands.items(): + cmd_styled = self._c(self.accent, f" {cmd:<{max_cmd}}") + desc_styled = self._c(_GRAY, f" {desc}") + print(f"{cmd_styled}{desc_styled}") + print() + + # ── Goodbye ─────────────────────────────────────────────────────── + + def print_goodbye(self): + """Print a styled goodbye message.""" + print(f"\n {_ICON_SMALL} {self._c(_GRAY, 'Goodbye!')}\n") + + # ── Prompt toolkit session factory ──────────────────────────────── + + def create_prompt_session(self): + """Create a prompt_toolkit PromptSession with skin styling. + + Returns: + A configured PromptSession, or None if prompt_toolkit unavailable. + """ + try: + from prompt_toolkit import PromptSession + from prompt_toolkit.history import FileHistory + from prompt_toolkit.auto_suggest import AutoSuggestFromHistory + from prompt_toolkit.formatted_text import FormattedText + + style = self.get_prompt_style() + + session = PromptSession( + history=FileHistory(self.history_file), + auto_suggest=AutoSuggestFromHistory(), + style=style, + enable_history_search=True, + ) + return session + except ImportError: + return None + + def get_input(self, pt_session, project_name: str = "", + modified: bool = False, context: str = "") -> str: + """Get input from user using prompt_toolkit or fallback. + + Args: + pt_session: A prompt_toolkit PromptSession (or None). + project_name: Current project name. + modified: Whether project has unsaved changes. + context: Optional context string. + + Returns: + User input string (stripped). + """ + if pt_session is not None: + from prompt_toolkit.formatted_text import FormattedText + tokens = self.prompt_tokens(project_name, modified, context) + return pt_session.prompt(FormattedText(tokens)).strip() + else: + raw_prompt = self.prompt(project_name, modified, context) + return input(raw_prompt).strip() + + # ── Toolbar builder ─────────────────────────────────────────────── + + def bottom_toolbar(self, items: dict[str, str]): + """Create a bottom toolbar callback for prompt_toolkit. + + Args: + items: Dict of label -> value pairs to show in toolbar. + + Returns: + A callable that returns FormattedText for the toolbar. + """ + def toolbar(): + from prompt_toolkit.formatted_text import FormattedText + parts = [] + for i, (k, v) in enumerate(items.items()): + if i > 0: + parts.append(("class:bottom-toolbar.text", " │ ")) + parts.append(("class:bottom-toolbar.text", f" {k}: ")) + parts.append(("class:bottom-toolbar", v)) + return FormattedText(parts) + return toolbar + + +# ── ANSI 256-color to hex mapping (for prompt_toolkit styles) ───────── + +_ANSI_256_TO_HEX = { + "\033[38;5;33m": "#0087ff", # audacity navy blue + "\033[38;5;34m": "#00af00", # qgis green + "\033[38;5;35m": "#00af5f", # shotcut teal + "\033[38;5;39m": "#00afff", # inkscape bright blue + "\033[38;5;40m": "#00d700", # libreoffice green + "\033[38;5;55m": "#5f00af", # obs purple + "\033[38;5;69m": "#5f87ff", # kdenlive slate blue + "\033[38;5;75m": "#5fafff", # default sky blue + "\033[38;5;80m": "#5fd7d7", # brand cyan + "\033[38;5;208m": "#ff8700", # blender deep orange + "\033[38;5;214m": "#ffaf00", # gimp warm orange +} diff --git a/QGIS/agent-harness/pytest.ini b/QGIS/agent-harness/pytest.ini new file mode 100644 index 0000000..7b001b9 --- /dev/null +++ b/QGIS/agent-harness/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +addopts = --import-mode=importlib diff --git a/QGIS/agent-harness/setup.py b/QGIS/agent-harness/setup.py new file mode 100644 index 0000000..388cb47 --- /dev/null +++ b/QGIS/agent-harness/setup.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""setup.py for cli-anything-qgis.""" + +from setuptools import find_namespace_packages, setup + +with open("cli_anything/qgis/README.md", "r", encoding="utf-8") as fh: + long_description = fh.read() + +setup( + name="cli-anything-qgis", + version="1.0.0", + author="cli-anything contributors", + author_email="", + description="CLI harness for QGIS using PyQGIS for project authoring and qgis_process for exports and processing.", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/HKUDS/CLI-Anything", + packages=find_namespace_packages(include=["cli_anything.*"]), + classifiers=[ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Scientific/Engineering :: GIS", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + ], + python_requires=">=3.10", + install_requires=[ + "click>=8.0.0", + "prompt-toolkit>=3.0.0", + ], + extras_require={ + "dev": [ + "pytest>=7.0.0", + "pytest-cov>=4.0.0", + "jinja2>=3.1.0", + ], + }, + entry_points={ + "console_scripts": [ + "cli-anything-qgis=cli_anything.qgis.qgis_cli:main", + ], + }, + package_data={ + "cli_anything.qgis": ["skills/*.md"], + }, + include_package_data=True, + zip_safe=False, +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..95d3614 --- /dev/null +++ b/README.md @@ -0,0 +1,1753 @@ +

  CLI-Anything: Making ALL Software Agent-Native

+ +
+HKUDS%2FCLI-Anything | Trendshift +
+ +

+ Today's Software Serves Humans👨‍💻. Tomorrow's Users will be Agents🤖.
+CLI-Anything: Bridging the Gap Between AI Agents and the World's Software

+

+ +**🌐 [CLI-Hub](https://hkuds.github.io/CLI-Anything/)**: `pip install cli-anything-hub` then `cli-hub install ` — browse, install, and manage all community-built CLIs. Want to add your own? [Open a PR](https://github.com/HKUDS/CLI-Anything/blob/main/CONTRIBUTING.md) — the hub updates instantly. + +**🎬 [See Demos](#-real-world-demos)**: Watch AI agents use generated CLIs plus preview, live preview, and trajectory loops to produce real artifacts — CAD builds, 3D scenes, diagrams, gameplay, subtitles, and more. + +**🙋 [Become a Contributor, or Request a CLI]**: [Join us](https://github.com/HKUDS/CLI-Anything/issues/new?template=contributor-signup.yml)! Sign up to build a new CLI harness — once reviewed and merged, you'll gain access as one of our community contributors! Wish CLI-Anything supported a specific software or service? Submit a [wishlist request](https://github.com/HKUDS/CLI-Anything/issues/new?template=cli-wishlist.yml)! + +

+ Quick Start + CLI Hub + Demos + Tests + Tech Report + License +

+ +

+ Python + Click + Pytest + Coverage + Output + Feishu +WeChat +

+ +**One Command Line**: Make any software agent-ready for Pi, OpenClaw, nanobot, Cursor, Claude Code, etc.  [**中文文档**](README_CN.md) | [**日本語ドキュメント**](README_JA.md) | [**Deutsch**](README_DE.md) + +

+ CLI-Anything typing demo +

+ +

+ CLI-Anything Teaser +

+ +--- + +## 📰 News + +> Thanks to all invaluable efforts from the community! More updates continuously on the way everyday.. + +- **2026-05-30** 🧭 **Hermes skill** proposed (#320), adding a CLI-Anything orchestration skill with installer scripts and HARNESS fallback guidance. 🗺️ **ArcGIS Pro** was proposed for the public registry (#318) as a Windows/ArcPy CLI for cartography, geoprocessing, feature editing, and live-Pro MCP workflows. + +- **2026-05-27** 🔧 **CLI-Hub** registry date updates now handle `python -m pip` install commands (#312), improving package-date detection for registry automation. + +- **2026-05-23** 📝 **Obsidian Agent CLI** was proposed for the public registry (#307), bringing a PyPI-installed Obsidian automation CLI with persistent agent memory workflows and a pinned skill link. + +- **2026-05-21** 🔒 **Sketch CLI** token-file handling was hardened against path traversal and symlink escapes (#304). + +- **2026-05-20** 📓 **Joplin CLI** was proposed (#300) with notebooks, notes, to-dos, tags, attachments, search/sync, import/export, server/E2EE helpers, full docs, packaged skill docs, and a 134-test validation baseline. + +- **2026-05-20** 🎛️ **Rekordbox CLI** merged (#252) with guarded SQLCipher write paths, backup-required forced writes, smoke coverage, and root skill sync. 📚 **Calibre CLI** merged (#223) with library/search/metadata/conversion/export workflows, 41 unit tests, real-Calibre E2E evidence, and root skill validation. 🧊 **3MF CLI** merged (#209) with mesh inspection, hole resizing, repair, comparison, and preserved triangle attributes. 🎙️ **MiniMax CLI** merged (#189) with chat/TTS workflows, JSON-safe model/voice listing, REPL quote handling, and smoke/E2E coverage. 🎮 **UEAtelier** joined the registry (#297) as an Unreal Editor MCP self-extension workbench with a Python CLI proxy. + +- **2026-05-19** 🛠️ Existing harnesses got a quality/security pass — **Zoom** downloads recordings from direct URLs (#294), **Obsidian** search now uses the Local REST API vendor content types (#289), **LibreOffice** headless conversion is more robust on macOS (#290), and XML/SVG/ODF/MLT/MusicXML/CSL parsing now routes untrusted input through `defusedxml` (#296). + +- **2026-05-18** 📈 README presentation refreshed with the Trendshift badge and centered project header polish (#285, #286), keeping the landing section focused on discovery and project momentum. + +- **2026-05-17** 🌐 **CLI-Hub** registry handling was hardened (#281) — registry entries are now copied before `_source` tagging, preventing cached or mocked registry data from being mutated in place. + +- **2026-05-16** 🔧 **n8n** received the REPL banner crash fix that later merged into main (#280), restoring the no-subcommand interactive startup path with regression coverage. + +
+Earlier news (Apr 10–18) + +- **2026-04-18** 🧩 **All SKILL.md files are now being unified under the top-level `skills/` directory** — every CLI skill can be installed from one canonical source with `npx skills add HKUDS/CLI-Anything --skill -g -y`. We also added root-skill validation CI, synced contribution / PR docs and REPL skill-path hints to the new layout, and refreshed the **CLI-Hub** install-first frontend around the new `npx skills` flow. + +- **2026-04-17** 🌐 **CLI-Hub** received another install UX pass — public registry metadata and skill coverage were tightened, visit counting was corrected, and the web hub was further refined. 🧪 **Shotcut** render output duration was fixed (#92). 📝 **SKILL** contribution paths were corrected for the new docs flow (#224), and the skill generator now safely handles empty intros (#203). + +- **2026-04-16** 🗺️ **QGIS CLI** merged (#207) — a full GIS / map authoring harness landed. 🧬 **UniMol Tools CLI** merged (#219) for molecular modeling workflows. 🌐 **CLI-Hub** also added more public CLIs, including **py4csr**, refreshed its generated meta-skill, corrected SKILL contribution docs, and fixed `apt-get` package extraction in skill generation (#204). + +- **2026-04-16** 📈 **Unreal Insights CLI** expanded — added background capture session control (`capture start/status/snapshot/stop`), engine-root-matched `UnrealInsights.exe` resolution/build flows, and refreshed docs/tests for the new orchestration workflow. + +- **2026-04-15** 🌐 **CLI-Hub** updated to **v0.2.0** — the PyPI package now supports public CLIs from multiple install sources (`pip`, `npm`, `brew`, bundled/system tools), backed by a new `public_registry.json`. The Hub frontend was redesigned with separate **CLI-Anything CLIs** and **Public CLIs** decks, and live end-to-end checks now cover real install, update, and uninstall flows across both pip and npm packages. + +- **2026-04-14** 🧭 **Safari CLI** merged (#212) and added to the Hub registry — browser automation via `safari-mcp`. 🎬 **Kdenlive** also received compatibility fixes for Gen 5 project output and invalid project generation. + +- **2026-04-13** 📓 **Obsidian CLI** merged (#211) — knowledge management harness via the Local REST API, with 48 unit tests and 7 E2E tests. ⛓️ **Eth2-Quickstart CLI** merged (#195) — Ethereum staking node management harness. 📚 **Zotero CLI** updated to v0.4.1 (#201) — now shipped from its standalone repo, and CLI-Hub gained support for remote `skill_md` URLs. + +- **2026-04-11** 🔗 **n8n CLI** merged (#188) — workflow automation harness for self-hosted automation flows. 🔧 **Exa CLI** fix (#205) added the `x-exa-integration` header for usage tracking. 📦 **CLI-Hub** also gained its PyPI auto-publish workflow and package refresh pipeline. + +- **2026-04-10** 📦 **CLI-Hub package manager** launched — `pip install cli-anything-hub` to browse, search, install, update, and uninstall CLI-Anything harnesses from one command. The web Hub also shipped its first install-focused frontend refresh and "Empower yourself" toolkit card. + +
+ +
+Earlier news (Apr 1–9) + +- **2026-04-09** 🧹 Cleanup and docs pass (#200) — fixed Openscreen test subtotals, added Openscreen to the Chinese README and project structure, and clarified `/cli-anything` command syntax in the docs. + +- **2026-04-08** 🎬 **Openscreen CLI** merged (#183) — screen recording editor harness with 101 tests. ☁️ **CloudAnalyzer CLI** merged (#181) — cloud cost analysis harness with 27 commands. 🌊 **SeaClip / PM2 / ChromaDB** harnesses merged (#129). + +- **2026-04-07** 🔄 **Dify Workflow CLI** merged (#191) — workflow automation wrapper. 🔧 **Inkscape** auto-save fix (#193, fixes #182). 🛡️ **DomShell security hardening** (#156) — URL validation and DOM sanitization for the browser CLI. 🥧 **Pi Coding Agent extension** merged (#178). + +- **2026-04-06** 🔍 **Exa CLI** merged (#172) — AI-powered web search and answers harness. 🎮 **Godot CLI** merged (#140) — game engine harness with a full demo-game E2E pipeline. ☁️ **CloudAnalyzer** review fixes and frontend improvements also landed. + +- **2026-04-03** 🧪 **WireMock CLI** merged (#170) — HTTP mock server harness for API testing. 🥧 **Pi Coding Agent** extension support also landed, and CLI demo recordings were added to the docs. + +- **2026-04-01** ⚔️ **Slay the Spire II CLI** merged (#148) — deck-building roguelike harness. 🎥 **VideoCaptioner CLI** merged (#166) — AI-powered video captioning harness. 🛰️ **IntelWatch** was added to the registry for B2B OSINT workflows. + +
+ +
+Earlier news (Mar 23–30) + +- **2026-03-30** 🏗️ **CLI-Anything v0.2.0** — HARNESS.md progressive disclosure redesign. Detailed guides extracted into `guides/` for on-demand loading. Phases 1–7 now contiguous. Key Principles and Rules merged into a single authoritative section. + +- **2026-03-29** 📐 Blender skill docs updated — enforce absolute render paths and correct prerequisites. + +- **2026-03-28** 🌐 **CLIBrowser** added to CLI-Hub registry for agent-accessible browser automation. + +- **2026-03-27** 📚 Zotero SKILL.md enhanced with agent-facing constraints; REPL config and executable resolution fixes. + +- **2026-03-26** 📖 **Zotero CLI** harness landed for Zotero desktop (library management, collections, citations). Draw.io custom ID bugfix (#132) and registry.json syntax fix. + +- **2026-03-25** 🎮 **RenderDoc CLI** merged for GPU frame capture analysis. FreeCAD updated for v1.1. Blender EEVEE engine name corrected. Zoom token permissions hardened. + +- **2026-03-24** 🏭 **FreeCAD CLI** added with 258 commands across 17 groups. **iTerm2** and **Teltonika RMS** harnesses added to registry. + +- **2026-03-23** 🤖 Launched **CLI-Hub meta-skill** — agents can now discover and install CLIs autonomously. **Krita CLI** harness merged for digital painting. + +
+ +
+Earlier news (Mar 11–22) + +- **2026-03-22** 🎵 **MuseScore CLI** merged with transpose, export, and instrument management. + +- **2026-03-21** 🔧 Infrastructure improvements — refined test harnesses and documentation across multiple CLIs. Enhanced Windows compatibility for several backends. + +- **2026-03-20** 🌐 **Novita AI** CLI added for OpenAI-compatible API access. Registry metadata improvements for better hub discovery. + +- **2026-03-19** 📦 Package structure refinements across harnesses. Improved SKILL.md generation with better command documentation. + +- **2026-03-18** 🧪 Test coverage expansion — additional E2E scenarios and edge case validation across multiple CLIs. + +- **2026-03-17** 🌐 Launched the **[CLI-Hub](https://hkuds.github.io/CLI-Anything/)** — a central registry where you can browse, search, and install any CLI with a single `pip` command. + +- **2026-03-16** 🤖 Added **SKILL.md generation** (Phase 6.5) — every generated CLI now ships with an AI-discoverable skill definition. + +- **2026-03-15** 🐾 Support for **OpenClaw** from the community! Merged Windows `cygpath` guard for cross-platform support. + +- **2026-03-14** 🔒 Fixed a GIMP Script-Fu path injection vulnerability and added **Japanese README** translation. + +- **2026-03-13** 🔌 **Qodercli** plugin officially merged as a community contribution with dedicated setup scripts. + +- **2026-03-12** 📦 **Codex skill** integration landed, bringing CLI-Anything to yet another AI coding platform. + +- **2026-03-11** 📞 **Zoom** video conferencing harness added as the 11th supported application. + +
+ +--- + +## 🤔 Why CLI? + +CLI is the universal interface for both humans and AI agents: + +• **Structured & Composable** - Text commands match LLM format and chain for complex workflows + +• **Lightweight & Universal** - Minimal overhead, works across all systems without dependencies + +• **Self-Describing** - --help flags provide automatic documentation agents can discover + +• **Proven Success** - Claude Code runs thousands of real workflows through CLI daily + +• **Agent-First Design** - Structured JSON output eliminates parsing complexity + +• **Deterministic & Reliable** - Consistent results enable predictable agent behavior + +## 🚀 Quick Start + +CLI-Anything has two equally useful starting points: + +| Goal | Start Here | +|------|------------| +| **Use the existing CLI ecosystem now** | Install `cli-anything-hub`, browse the registry, and install ready-made CLIs. | +| **Build a new agent-native CLI** | Install the CLI-Anything plugin/skill for your agent, then run the 7-phase harness generator. | + +
+Phase 1: Empower yourself — your CLI toolkit + +Install the CLI-Hub package manager first. It lets you browse, search, inspect, install, update, uninstall, and launch CLI-Anything harnesses plus public third-party CLIs from one command. + +**PIP** + +```bash +pip install cli-anything-hub +``` + +| Command | What it does | +|---------|--------------| +| `cli-hub list` | Browse the registry | +| `cli-hub search ` | Search by keyword | +| `cli-hub info ` | Inspect one CLI | +| `cli-hub install ` | Install from CLI-Hub | +| `cli-hub update ` | Update an install | +| `cli-hub uninstall ` | Remove an install | +| `cli-hub launch [args...]` | Run an installed CLI | + +```bash +# Browse what is available +cli-hub list +cli-hub search image + +# Install and try a CLI immediately +cli-hub install gimp +cli-hub info gimp +cli-hub launch gimp +``` + +> Some CLIs wrap real desktop or backend software. If a selected CLI needs GIMP, Blender, LibreOffice, or another upstream tool, install that upstream application too. + +
+ +
+Phase 1.5: Empower your agents — install in one command + +Give SKILL-compatible agents the CLI-Hub meta-skill so they can discover and install the right CLI for a task. + +**Also available on:** [ClawHub](https://clawhub.ai/yuh-yang/cli-anything-hub), [SkillHub](https://www.skillhub.club/web/skills/itsyuhao-cli-anything-hub), [SkillHub.cn](https://skillhub.cn/skills/cli-hub-meta-skill) + +**NPX SKILLS** + +```bash +npx skills add HKUDS/CLI-Anything --skill cli-hub-meta-skill -g -y +``` + +**Works with:** OpenClaw, Nanobot, Claude Code, Codex, Reasonix, Antigravity, and other SKILL-compatible agents. + +Then prompt: + +```text +Find appropriate CLI software in CLI-Hub and complete the task: ... +``` + +
+ +
+Phase 3: Build a new CLI when the registry does not have one yet + +Use the CLI-Anything generator when you need a new harness for software, a codebase, or an internal tool that is not already available through CLI-Hub. + +**Prerequisites** + +- **Python 3.10+** +- Target software or source repo available locally or online +- A supported AI coding agent: [Claude Code](#-claude-code) | [Pi](#-pi-coding-agent) | [OpenClaw](#-openclaw) | [OpenCode](#-opencode) | [Codex](#-codex) | [Hermes](#-hermes) | [Reasonix](#-reasonix) | [Qodercli](#-qodercli) | [GitHub Copilot CLI](#-github-copilot-cli) | [More Platforms](#-more-platforms-coming-soon) + +### Pick Your Agent Platform + +
+

⚡ Claude Code

+ +**Step 1: Add the Marketplace** + +CLI-Anything is distributed as a Claude Code plugin marketplace hosted on GitHub. + +```bash +# Add the CLI-Anything marketplace +/plugin marketplace add HKUDS/CLI-Anything +``` + +**Step 2: Install the Plugin** + +```bash +# Install the cli-anything plugin from the marketplace +/plugin install cli-anything +``` + +That's it. The plugin is now available in your Claude Code session. + +> **Note for Win Users:** Claude Code runs shell commands via `bash`. On Windows, install Git for Windows (includes `bash` and +`cygpath`) or use WSL; otherwise commands may fail with `cygpath: command not found`. + +**Step 3: Build a CLI in One Command** + +```bash +# /cli-anything +# Generate a complete CLI for GIMP (all 7 phases) +/cli-anything ./gimp +``` + +Command compatibility across Claude Code versions: +- Use `/cli-anything` as the primary entrypoint. +- On older builds where `/cli-anything` isn't recognized **after confirming the plugin is installed and loaded**, try the legacy entry form `/cli-anything:cli-anything`. +- Auxiliary commands keep the `:subcommand` form (e.g. `/cli-anything:refine`). + +If you see `Unknown skill: cli-anything`, focus on plugin install/load first (both entry forms reference the same skill, so swapping forms won't help): +1. Reload plugin commands: `/reload-plugins` +2. Verify the plugin is loaded: `/help cli-anything` (CLI-Anything help/commands should appear) +3. Reinstall from marketplace if needed: + - `/plugin marketplace add HKUDS/CLI-Anything` + - `/plugin install cli-anything` +4. After confirming the plugin is available, retry the entry command: + - Preferred: `/cli-anything ./gimp` + - Older builds only: `/cli-anything:cli-anything ./gimp` + +This runs the full pipeline: +1. 🔍 **Analyze** — Scans source code, maps GUI actions to APIs +2. 📐 **Design** — Architects command groups, state model, output formats +3. 🔨 **Implement** — Builds Click CLI with REPL, JSON output, undo/redo +4. 📋 **Plan Tests** — Creates TEST.md with unit + E2E test plans +5. 🧪 **Write Tests** — Implements comprehensive test suite +6. 📝 **Document** — Updates TEST.md with results +7. 📦 **Publish** — Creates `setup.py`, installs to PATH + +**Step 4 (Optional): Refine and Improve the CLI** + +After the initial build, you can iteratively refine the CLI to expand coverage and add missing capabilities: + +```bash +# Broad refinement — agent analyzes gaps across all capabilities +/cli-anything:refine ./gimp + +# Focused refinement — target a specific functionality area +/cli-anything:refine ./gimp "I want more CLIs on image batch processing and filters" +``` + +The refine command performs gap analysis between the software's full capabilities and current CLI coverage, then implements new commands, tests, and documentation for the identified gaps. You can run it multiple times to steadily expand coverage — each run is incremental and non-destructive. + +
+Alternative: Manual Installation + +If you prefer not to use the marketplace: + +```bash +# Clone the repo +git clone https://github.com/HKUDS/CLI-Anything.git + +# Copy plugin to Claude Code plugins directory +cp -r CLI-Anything/cli-anything-plugin ~/.claude/plugins/cli-anything + +# Reload plugins +/reload-plugins +``` + +
+ +
+ +
+

⚡ Pi Coding Agent

+ +**Step 1: Install the Extension** + +The extension lives at `.pi-extension/cli-anything/` in this repository. Install it globally so `/cli-anything` commands are available in **all** Pi projects: + +```bash +# Clone the repo +git clone https://github.com/HKUDS/CLI-Anything.git +cd CLI-Anything + +# Install globally into Pi's extensions directory +bash .pi-extension/cli-anything/install.sh +``` + +To uninstall: + +```bash +bash .pi-extension/cli-anything/install.sh --uninstall +``` + +> **How it works:** `install.sh` copies the extension files (including HARNESS.md, commands, guides, scripts, and templates from `cli-anything-plugin/`) into `~/.pi/agent/extensions/cli-anything/`, which Pi auto-discovers on startup. Run `/reload` in Pi or restart Pi to activate. + +**Step 2: Build a CLI in One Command** + +Once the extension is loaded, the following commands are available: + +```bash +# Generate a complete CLI for GIMP (all 7 phases) +/cli-anything ./gimp + +# Build from a GitHub repo +/cli-anything https://github.com/blender/blender +``` + +**Step 3 (Optional): Refine and Improve the CLI** + +```bash +# Broad refinement — agent analyzes gaps across all capabilities +/cli-anything:refine ./gimp + +# Focused refinement — target a specific functionality area +/cli-anything:refine ./gimp "batch processing and filters" +``` + +**Available Commands** + +| Command | Description | +|---------|-------------| +| `/cli-anything ` | Build a complete CLI harness | +| `/cli-anything:refine [focus]` | Refine an existing CLI harness | +| `/cli-anything:test ` | Run tests for a CLI harness | +| `/cli-anything:validate ` | Validate a CLI harness | +| `/cli-anything:list [options]` | List all CLI-Anything tools | + +
+ +
+

⚡ OpenCode (Experimental)

+ +**Step 1: Install the Commands** + +> **Note:** Please upgrade to the latest OpenCode. Older versions may use a different commands path. + +Copy the CLI-Anything commands **and** `HARNESS.md` to your OpenCode commands directory: + +```bash +# Clone the repo +git clone https://github.com/HKUDS/CLI-Anything.git + +# Global install (available in all projects) +cp CLI-Anything/opencode-commands/*.md ~/.config/opencode/commands/ +cp CLI-Anything/cli-anything-plugin/HARNESS.md ~/.config/opencode/commands/ + +# Or project-level install +cp CLI-Anything/opencode-commands/*.md .opencode/commands/ +cp CLI-Anything/cli-anything-plugin/HARNESS.md .opencode/commands/ +``` + +> **Note:** Please upgrade to the latest OpenCode. Older versions use `command/` (singular) instead of `commands/`. If `commands/` does not exist, use `command/` for both global and project-level installs. + +> **Note:** `HARNESS.md` is the methodology spec that all commands reference. It must be in the same directory as the commands. + +This adds 5 slash commands: `/cli-anything`, `/cli-anything-refine`, `/cli-anything-test`, `/cli-anything-validate`, and `/cli-anything-list`. + +**Step 2: Build a CLI in One Command** + +```bash +# Generate a complete CLI for GIMP (all 7 phases) +/cli-anything ./gimp + +# Build from a GitHub repo +/cli-anything https://github.com/blender/blender +``` + +The command runs as a subtask and follows the same 7-phase methodology as Claude Code. + +**Step 3 (Optional): Refine and Improve the CLI** + +```bash +# Broad refinement — agent analyzes gaps across all capabilities +/cli-anything-refine ./gimp + +# Focused refinement — target a specific functionality area +/cli-anything-refine ./gimp "batch processing and filters" +``` + +
+ +
+

⚡ Goose (Desktop / CLI) Experimental Community

+ +**Step 1: Install Goose** + +Install Goose (Desktop or CLI) using the official Goose instructions for your OS. + +**Step 2: Configure a CLI Provider** + +Configure Goose to use a CLI provider such as Claude Code, and make sure that CLI is installed and authenticated. + +**Step 3: Use CLI-Anything in a Goose Session** + +Once Goose is configured, start a session and use the same CLI-Anything commands described above for Claude Code, for example: + +```bash +/cli-anything ./gimp +/cli-anything:refine ./gimp "batch processing and filters" +``` + +> Note: When Goose runs through a CLI provider, it uses that provider's capabilities and command format. +
+ +
+ +

⚡ Qodercli Community

+ +**Step 1: Register the Plugin** + +```bash +git clone https://github.com/HKUDS/CLI-Anything.git +bash CLI-Anything/qoder-plugin/setup-qodercli.sh +``` + +This registers the cli-anything plugin in `~/.qoder.json`. Start a new Qodercli session after registration. + +**Step 2: Use CLI-Anything from Qodercli** + +```bash +/cli-anything ./gimp +/cli-anything:refine ./gimp "batch processing and filters" +/cli-anything:validate ./gimp +``` +
+ +
+ +

⚡ OpenClaw Community

+ +**Step 1: Install the Skill** + +CLI-Anything provides a native OpenClaw `SKILL.md` file. Copy it to your OpenClaw skills directory: + +```bash +# Clone the repo +git clone https://github.com/HKUDS/CLI-Anything.git + +# Install to the global skills folder +mkdir -p ~/.openclaw/skills/cli-anything +cp CLI-Anything/openclaw-skill/SKILL.md ~/.openclaw/skills/cli-anything/SKILL.md +``` + +**Step 2: Build a CLI** + +Now you can invoke the skill inside OpenClaw: + +`@cli-anything build a CLI for ./gimp` + +The skill follows the same 7-phase methodology as Claude Code and OpenCode. + +
+ +
+ +

⚡ Codex Experimental Community

+ +**Step 1: Install the Skill** + +Run the bundled installer: + +```bash +# Clone the repo +git clone https://github.com/HKUDS/CLI-Anything.git + +# Install the skill +bash CLI-Anything/codex-skill/scripts/install.sh +``` + +On Windows PowerShell, use: + +```powershell +.\CLI-Anything\codex-skill\scripts\install.ps1 +``` + +This installs the skill to `$CODEX_HOME/skills/cli-anything` (or `~/.codex/skills/cli-anything` if `CODEX_HOME` is unset). +The installer also vendors the canonical `HARNESS.md`, command specifications, +on-demand guides, reusable helper scripts, skill template, and preview protocol +into the installed skill's `references/` and `scripts/` directories. +This keeps the Codex skill self-contained while using `cli-anything-plugin/` as +the source of truth. + +Restart Codex after installation so it is discovered. + +**Step 2: Use CLI-Anything from Codex** + +Describe the task in natural language, for example: + +```text +Use CLI-Anything to build a harness for ./gimp +Use CLI-Anything to refine ./shotcut for picture-in-picture workflows +Use CLI-Anything to validate ./libreoffice +Use CLI-Anything to list generated harnesses under the current directory +``` + +The Codex skill adapts the same methodology used by the Claude Code plugin and +OpenCode commands, while keeping the generated Python harness format unchanged. + +To verify the self-contained Codex installation locally: + +```bash +bash CLI-Anything/codex-skill/tests/test_install.sh +``` +
+ +
+ +

⚡ Hermes Experimental Community

+ +**Step 1: Install the Skill** + +Run the bundled installer: + +```bash +# Clone the repo +git clone https://github.com/HKUDS/CLI-Anything.git + +# Install the skill +bash CLI-Anything/hermes-skill/scripts/install.sh +``` + +On Windows PowerShell, use: + +```powershell +.\CLI-Anything\hermes-skill\scripts\install.ps1 +``` + +This installs the skill to `$HERMES_HOME/skills/cli-anything-hermes` (or `~/.hermes/skills/cli-anything-hermes` if `HERMES_HOME` is unset). + +Restart [Hermes Agent](https://github.com/NousResearch/hermes-agent) after installation so it is discovered. + +**Step 2: Use CLI-Anything from Hermes** + +Describe the task in natural language, for example: + +```text +Use CLI-Anything to build a harness for ./gimp +Use CLI-Anything to refine ./shotcut for picture-in-picture workflows +Use CLI-Anything to validate ./libreoffice +``` + +The Hermes skill adapts the same methodology used by the Claude Code plugin and +Codex skill, binding Hermes's `terminal`, `execute_code`, `delegate_task`, and +`read_file` / `write_file` / `patch` tools to the 7-phase harness workflow while +keeping the generated Python harness format unchanged. +
+ +
+ +

⚡ Reasonix Experimental Community

+ +**Step 1: Install the Skill** + +Run the bundled installer: + +```bash +# Clone the repo +git clone https://github.com/HKUDS/CLI-Anything.git + +# Install the skill +bash CLI-Anything/reasonix-skill/scripts/install.sh +``` + +On Windows PowerShell, use: + +```powershell +.\CLI-Anything\reasonix-skill\scripts\install.ps1 +``` + +This installs the skill to Reasonix's global skill directory at `~/.reasonix/skills/cli-anything`. + +Restart Reasonix after installation so it is discovered. + +**Step 2: Use CLI-Anything from Reasonix** + +Describe the task in natural language, for example: + +```text +Use CLI-Anything to build a harness for ./gimp +Use CLI-Anything to refine ./shotcut for picture-in-picture workflows +Use CLI-Anything to validate ./libreoffice +``` + +The Reasonix skill adapts the same methodology used by the Claude Code plugin and +Codex/Hermes skills, binding Reasonix's `bash`, `write_file`, `edit_file`, +`multi_edit`, `grep`, `glob`, and optional `mcp__codegraph__search` / `mcp__codegraph__context` tools to the +7-phase harness workflow while keeping the generated Python harness format unchanged. +
+ +
+ +

⚡ GitHub Copilot CLI Community

+ +**Step 1: Install the Plugin** + +```bash +git clone https://github.com/HKUDS/CLI-Anything.git +cd CLI-Anything +copilot plugin install ./cli-anything-plugin +``` + +This installs the CLI-Anything plugin to GitHub Copilot CLI. The plugin should now be available in your GitHub Copilot CLI session. + +**Step 2: Use CLI-Anything from GitHub Copilot CLI** + +```bash +/cli-anything ./gimp +/cli-anything:refine ./gimp "batch processing and filters" +/cli-anything:validate ./gimp +``` + +
+ +
+

🔮 More Platforms (Coming Soon)

+ +CLI-Anything is designed to be platform-agnostic. Support for more AI coding agents is planned: + +- **Codex** — available via the bundled skill in `codex-skill/` +- **Cursor** — coming soon +- **Windsurf** — coming soon +- **Your favorite tool** — contributions welcome! See the `opencode-commands/` directory for a reference implementation. + +
+ +### Use the Generated CLI + +Regardless of which platform you used to build it, the generated CLI works the same way: + +```bash +# Install to PATH +cd gimp/agent-harness && pip install -e . + +# Use from anywhere +cli-anything-gimp --help +cli-anything-gimp project new --width 1920 --height 1080 -o poster.json +cli-anything-gimp --json layer add -n "Background" --type solid --color "#1a1a2e" + +# Enter interactive REPL +cli-anything-gimp +``` + +Each in-repo harness now has a canonical [`SKILL.md`](#-skillmd-generation) at `skills/cli-anything-/SKILL.md`, which makes the monorepo directly discoverable via `npx skills add HKUDS/CLI-Anything --list`. Installed harness packages still ship a compatibility copy at `cli_anything//skills/SKILL.md`, and the REPL banner prefers the repo-root canonical file when present, falling back to the packaged copy otherwise. + +
+ +--- + +## 🤖 Empower Your Agents with CLI-Hub + +CLI-Hub lets agents autonomously discover, install, and use the CLIs they need. + +```bash +npx skills add HKUDS/CLI-Anything --skill cli-hub-meta-skill -g -y +``` + +**Also available on:** [ClawHub](https://clawhub.ai/yuh-yang/cli-anything-hub), [SkillHub](https://www.skillhub.club/web/skills/itsyuhao-cli-anything-hub), [SkillHub.cn](https://skillhub.cn/skills/cli-hub-meta-skill) + +Then prompt: + +```text +Find appropriate CLI software in CLI-Hub and complete the task: ... +``` + +The meta-skill points agents to the live CLI-Hub catalog, where they can choose a CLI, install it, and read its own `SKILL.md` for task-specific usage. + +--- + +## 💡 CLI-Anything's Vision: Building Agent-Native Software + +• 🌐 **Universal Access** - Every software becomes instantly agent-controllable through structured CLI. + +• 🔗 **Seamless Integration** - Agents control any application without APIs, GUI, rebuilding or complex wrappers. + +• 🚀 **Future-Ready Ecosystem** - Transform human-designed software into agent-native tools with one command. + +--- + +## 🔧 When to Use CLI-Anything + +| Category | How to be Agent-native | Notable Examples | +|----------|----------------------|----------| +| **📂 GitHub Repositories** | Transform any open-source project into agent-controllable tools through automatic CLI generation | VSCodium, WordPress, Calibre, Zotero, Joplin, Logseq, Penpot, Super Productivity | +| **🤖 AI/ML Platforms** | Automate model training, inference pipelines, and hyperparameter tuning through structured commands | Stable Diffusion WebUI, ComfyUI, Ollama, InvokeAI, Text-generation-webui, Open WebUI, Fooocus, Kohya_ss, AnythingLLM, SillyTavern | +| **📊 Data & Analytics** | Enable programmatic data processing, visualization, and statistical analysis workflows | JupyterLab, Apache Superset, Metabase, Redash, DBeaver, KNIME, Orange, OpenSearch Dashboards, Lightdash | +| **💻 Development Tools** | Streamline code editing, building, testing, and deployment processes via command interfaces | Jenkins, Gitea, Hoppscotch, Portainer, pgAdmin, SonarQube, ArgoCD, OpenLens, Insomnia, Beekeeper Studio, **[iTerm2](https://iterm2.com)** | +| **🎨 Creative & Media** | Control content creation, editing, and rendering workflows programmatically | Blender, GIMP, OBS Studio, Audacity, WaveTone, Krita, Kdenlive, Shotcut, Inkscape, Darktable, LMMS, Ardour | +| **🎮 Game Development** | Manage game projects, scenes, exports, and scripting through headless engine interfaces | **[Godot Engine](https://godotengine.org)**, **[s&box](https://sbox.game)** | +| **🔬 Scientific Computing** | Automate research workflows, simulations, and complex calculations | ImageJ, FreeCAD, QGIS, ParaView, Gephi, LibreCAD, Stellarium, KiCad, JASP, Jamovi | +| **🏢 Enterprise & Office** | Convert business applications and productivity tools into agent-accessible systems | NextCloud, GitLab, Grafana, Mattermost, LibreOffice, AppFlowy, NocoDB, Odoo (Community), Plane, ERPNext | +| **📞 Communication & Collaboration** | Automate meeting scheduling, participant management, recording retrieval, and reporting through structured CLI | Zoom, Jitsi Meet, BigBlueButton, Mattermost | +| **📐 Diagramming & Visualization** | Create and manipulate diagrams, flowcharts, architecture diagrams, and visual documentation programmatically | Draw.io (diagrams.net), Mermaid, PlantUML, Excalidraw, yEd | +| **🌐 Network & Infrastructure** | Manage network services, DNS, ad-blocking, and infrastructure through structured CLI commands | AdGuardHome | +| **🧪 Testing & Mocking** | Control HTTP mock servers, manage test stubs, record and replay API traffic for integration testing | **[WireMock](https://wiremock.org)** | +| **🔬 Graphics & GPU Debugging** | Analyze GPU frame captures, inspect pipeline state, export shaders, and diff rendering state | RenderDoc | +| **🎬 Video & Subtitles** | Transcribe speech, translate subtitles, burn styled captions into video — full captioning pipeline | VideoCaptioner | +| **🔍 AI-Native Search** | Neural and deep web search with structured content retrieval through embedding-based APIs | [Exa](https://exa.ai) | +| **✨ AI Content Generation** | Generate professional deliverables (slides, docs, diagrams, websites, research reports) through AI-powered cloud APIs | [AnyGen](https://www.anygen.io), Gamma, Beautiful.ai, Tome | + +--- + +## CLI-Anything's Key Features + +### The Agent-Software Gap +AI agents are great at reasoning but terrible at using real professional software. Current solutions are fragile UI automation, limited APIs, or dumbed-down reimplementations that miss 90% of functionality. + +**CLI-Anything's Solution**: Transform any professional software into agent-native tools without losing capabilities. + +| **Current Pain Point** | **CLI-Anything's Fix** | +|----------|----------------------| +| 🤖 "AI can't use real tools" | Direct integration with actual software backends (Blender, LibreOffice, FFmpeg) — full professional capabilities, zero compromises | +| 💸 "UI automation breaks constantly" | No screenshots, no clicking, no RPA fragility. Pure command-line reliability with structured interfaces | +| 📊 "Agents need structured data" | Built-in JSON output for seamless agent consumption + human-readable formats for debugging | +| 🔧 "Custom integrations are expensive" | One Claude plugin auto-generates CLIs for ANY codebase through proven 7-phase pipeline | +| ⚡ "Prototype vs Production gap" | 2,280+ tests with real software validation. Battle-tested across 18 major applications | + +--- + +## 🎯 What Can You Do with CLI-Anything? + + + + + + + +
+ +### 🛠️ Let Agents Take Your Workflows + +Professional or everyday — just throw the codebase at `/cli-anything`. GIMP, Blender, Shotcut for creative work. LibreOffice, OBS Studio for daily tasks. Don't have the source? Find an open-source alternative and throw *that* in. You'll instantly get a full CLI your agents can use. + + + +### 🔗 Unify Scattered APIs into One CLI + +Tired of juggling fragmented web service APIs? Feed the docs or SDK manuscripts to `/cli-anything` and your agents get a **powerful, stateful CLI** that wraps those individual endpoints into coherent command groups. One tool instead of dozens of raw API calls — stronger capabilities while saving tokens. + + + +### 🚀 Replace or Supercharge GUI Agents + +CLI-Anything can flat-out **replace GUI-based agent approaches** — no more screenshots, no brittle pixel-clicking. But here's the fun part: once you `/cli-anything` a GUI software, you can **synthesize agent tasks, evaluators, and benchmarks** entirely via code and terminal — fully automated, iteratively refinable, massively more efficient. + +
+ +--- + +## ✨ ⚙️ How CLI-Anything Works + + + + + + + + + + + + + + +
+ +### 🏗️ Fully Automated 7-Phase Pipeline +From codebase analysis to PyPI publishing — the plugin handles architecture design, implementation, test planning, test writing, and documentation completely automatically. + + + +### 🎯 Authentic Software Integration +Direct calls to real applications for actual rendering. LibreOffice generates PDFs, Blender renders 3D scenes, Audacity processes audio via sox. **Zero compromises**, **Zero toy implementations**. + +
+ +### 🔁 Smart Session Management +Persistent project state with undo/redo capabilities, plus unified REPL interface (ReplSkin) that delivers consistent interactive experience across all CLIs. + + + +### 📦 Zero-Config Installation +Simple pip install -e . puts cli-anything- directly on PATH. Agents discover tools via standard which commands. No setup, no wrappers. + +
+ +### 🧪 Production-Grade Testing +Multi-layered validation: unit tests with synthetic data, end-to-end tests with real files and software, plus CLI subprocess verification of installed commands. + + + +### 🐍 Clean Package Architecture +All CLIs organized under cli_anything.* namespace — conflict-free, pip-installable, with consistent naming: cli-anything-gimp, cli-anything-blender, etc. + +
+ +### 🤖 SKILL.md Generation + +Each generated CLI now has a canonical `SKILL.md` at `skills/cli-anything-/SKILL.md`. This makes the current monorepo directly consumable by `npx skills`, while a packaged compatibility copy at `cli_anything//skills/SKILL.md` preserves installed-harness behavior. + +**What SKILL.md provides:** +- **YAML frontmatter** with name and description for agent skill discovery +- **Command groups** with all available subcommands documented +- **Usage examples** for common workflows +- **Agent-specific guidance** for JSON output, error handling, and programmatic use + +SKILL.md files are auto-generated during Phase 6.5 of the pipeline using `skill_generator.py`, which extracts metadata directly from the CLI's Click decorators, setup.py, and README. The generator now writes the canonical repo-root skill file and refreshes the package-local compatibility copy used by installed harnesses. Inside this repo, the REPL banner points agents to the canonical root skill path; after `pip install`, it falls back to the packaged copy. + +--- + +## 🎬 Real-World Demos + +AI agents using generated CLIs to produce complete, useful artifacts — no GUI needed. + +### FreeCAD — Curiosity Rover via Preview, Live Preview, and Trajectory + +> **Harness:** `cli-anything-freecad` | **Preview Stack:** `preview` + `preview live` + `trajectory.json` | **Artifact:** Agent-built Curiosity-style rover + +An agent incrementally assembles a Curiosity-inspired rover while publishing real FreeCAD preview bundles, refreshing a live preview session, and recording command-to-preview history for later replay. The resulting demo shows the artifact evolving step by step before the final showcase. + +

+ FreeCAD Curiosity rover demo driven by preview, live preview, and trajectory history +

+ +

+ README GIF generated from the full local demo video with a speed-adjusted, high-quality ffmpeg palette workflow. +

+ +### Blender — Orbital Relay Drone via Preview, Live Preview, and Trajectory + +> **Harness:** `cli-anything-blender` | **Preview Stack:** `preview` + `preview live` + `trajectory.json` | **Artifact:** Agent-built orbital relay drone + +An agent uses the Blender harness to grow a hard-surface orbital relay drone under a real preview loop: each stage pushes new render-backed bundles, the live session tracks the current head, and the trajectory ties every command to the matching visual state. The demo finishes with the completed scene ready for a polished turntable. + +

+ Blender orbital relay drone demo driven by preview, live preview, and trajectory history +

+ +

+ README GIF generated from the full local demo video with a speed-adjusted, high-quality ffmpeg palette workflow. +

+ +### Draw.io — HTTPS Handshake Diagram + +> **Harness:** `cli-anything-drawio` | **Time:** ~4 min | **Artifact:** `.drawio` + `.png` + +An agent creates a full HTTPS connection lifecycle diagram from scratch — TCP three-way handshake, TLS negotiation, encrypted data exchange, and TCP four-way termination — entirely through CLI commands. + +

+ Draw.io CLI demo: building an HTTPS handshake diagram +

+ +
+Final artifact +

+ HTTPS handshake sequence diagram +

+
+ +*Contributed by [@zhangxilong-43](https://github.com/zhangxilong-43)* + +### Slay the Spire II — Game Automation + +> **Harness:** `cli-anything-slay-the-spire-ii` | **Artifact:** Automated gameplay session + +An agent plays through a Slay the Spire II run using the CLI harness — reading game state, selecting cards, choosing paths, and making strategic decisions in real-time. + +

+ Slay the Spire II CLI gameplay demo +

+ +*Contributed by [@TianyuFan0504](https://github.com/TianyuFan0504)* + +### VideoCaptioner — Auto-Generated Subtitles + +> **Harness:** `cli-anything-videocaptioner` | **Artifact:** Captioned video frames + +An agent uses the VideoCaptioner CLI to automatically generate and overlay styled subtitles onto video content, with bilingual text rendering and customizable formatting. + + + + + + + + + + +
Sub ASub B
Video frame before captioningVideo frame after captioning
+ +*Contributed by [@WEIFENG2333](https://github.com/WEIFENG2333)* + +### ArcGIS Pro — Live Agent Cartography via the MCP Bridge + +> **Harness:** [`arcgis-pro`](https://github.com/Jasper0122/CLI-Anything-Arcgis-Pro) (registered in [`public_registry.json`](public_registry.json)) | **Mode:** live-Pro MCP bridge | **Artifact:** Agent-driven map navigation in a running ArcGIS Pro session + +An agent drives a **live, open ArcGIS Pro session** through an MCP bridge — reading the project, zooming the map to feature layers, running geoprocessing, and exporting layouts — while each step executes inside Pro as you watch. ArcGIS Pro is Esri's commercial GIS desktop (Windows-only, licensed), so this wraps its official **ArcPy / ArcGIS Pro SDK** rather than being generated from source — the ArcGIS Pro counterpart to the QGIS harness. + +

+ ArcGIS Pro live-bridge demo: an agent driving a running ArcGIS Pro session over MCP +

+ +

+ README GIF generated from the full local demo video with a high-quality ffmpeg palette workflow. +

+ +*Contributed by [@Jasper0122](https://github.com/Jasper0122)* + +*More CLI demos coming soon.* + +--- + +## 🎬 Demonstrations + +### 🎯 General-Purpose +CLI-Anything works on any software with a codebase — no domain restrictions or architectural limitations. + +### 🏭 Professional-Grade Testing +Tested across 18 diverse, complex applications spanning creative, productivity, communication, diagramming, AI image generation, AI content generation, network ad blocking, local LLM inference, native debugging, and graphics profiling domains previously inaccessible to AI agents. + +### 🎨 Diverse Domain Coverage +From creative workflows (image editing, 3D modeling, vector graphics) to production tools (audio, office, live streaming, video editing). + +### ✅ Full CLI Generation +Each application received complete, production-ready CLI interfaces — not demos, but comprehensive tool access preserving full capabilities. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SoftwareDomainCLI CommandBackendTests
🎨 GIMPImage Editingcli-anything-gimpPillow + GEGL/Script-Fu✅ 107
🧊 Blender3D Modeling & Renderingcli-anything-blenderbpy (Python scripting)✅ 208
✏️ InkscapeVector Graphicscli-anything-inkscapeDirect SVG/XML manipulation✅ 202
🎵 AudacityAudio Productioncli-anything-audacityPython wave + sox✅ 161
WaveToneAudio Transcriptioncli-anything-wavetoneJSON manifest + real WaveTone launchNew
🌐 BrowserBrowser Automationcli-anything-browserDOMShell MCP + Accessibility TreeNew
Web Yu-priJapan Post Shipping Labelscli-anything-web-yu-priPlaywright + Web Yu-pri formsNew
📄 LibreOfficeOffice Suite (Writer, Calc, Impress)cli-anything-libreofficeODF generation + headless LO✅ 158
OpenRefineData Cleaningcli-anything-openrefineOpenRefine local HTTP API✅ 76
n8nWorkflow Automationcli-anything-n8nn8n REST API v1.1.155+ cmds
📧 MailchimpEmail Marketing & Automationcli-anything-mailchimpMailchimp Marketing API v3.0303 cmds
📚 ZoteroReference Managementcli-anything-zoteroLocal SQLite + connector + Local APINew
📖 CalibreE-book Library Managementcli-anything-calibrecalibredb + ebook-convert + ebook-meta58
📓 JoplinNote-taking & To-doscli-anything-joplinJoplin terminal CLI subprocess✅ 134
📝 MubuKnowledge Management & Outliningcli-anything-mubuLocal Mubu data + sync logs✅ 96
📹 OBS StudioLive Streaming & Recordingcli-anything-obs-studioJSON scene + obs-websocket✅ 153
📱 NSLoggeriOS/macOS Log Capturecli-anything-nsloggerNSLogger wire protocol + native macOS Bonjour✅ 139
🎞️ KdenliveVideo Editingcli-anything-kdenliveMLT XML + melt renderer✅ 155
🎬 ShotcutVideo Editingcli-anything-shotcutDirect MLT XML + melt✅ 154
🎬 OpenscreenScreen Recording Editorcli-anything-openscreenffmpeg backend✅ 101
📞 ZoomVideo Conferencingcli-anything-zoomZoom REST API (OAuth2)✅ 22
🎵 MuseScoreMusic Notationcli-anything-musescoremscore CLI (MSCX/MusicXML)✅ 56
📐 Draw.ioDiagrammingcli-anything-drawiomxGraph XML + draw.io CLI✅ 138
EEZ StudioEmbedded UI / Instrument Automationcli-anything-eez-studio.eez-project JSON + EEZ Studio Node backendNew
⛓️ ETH2 QuickStartDevOps / Ethereumcli-anything-eth2-quickstarteth2-quickstart shell automation + JSON health checks✅ 18
🧜 Mermaid Live EditorDiagrammingcli-anything-mermaidMermaid state + mermaid.ink renderer✅ 10
✨ AnyGenAI Content Generationcli-anything-anygenAnyGen REST API (anygen.io)✅ 50
🧠 NotebookLMAI Research Assistantcli-anything-notebooklmNotebookLM CLI wrapper (experimental)✅ 21
🧩 Dify WorkflowDify DSL Editingcli-anything-dify-workflowWrapper around the open-source dify-workflow CLI✅ 11
🖼️ ComfyUIAI Image Generationcli-anything-comfyuiComfyUI REST API✅ 70
🛡️ AdGuard HomeNetwork-wide Ad Blockingcli-anything-adguardhomeAdGuard Home REST API✅ 36
🦙 OllamaLocal LLM Inferencecli-anything-ollamaOllama REST API✅ 98
🧬 Uni-Mol ToolsMolecular Property Prediction (AI4S)cli-anything-unimol-toolsUni-Mol molecular ML backend✅ 67
🎬 VideoCaptionerAI Video Captioningcli-anything-videocaptionervideocaptioner CLI (PyPI)✅ 26
🎨 SketchUI Designsketch-clisketch-constructor (Node.js)✅ 19
🎮 Godot EngineGame Developmentcli-anything-godotGodot 4.x headless subprocess✅ 24
📦 s&boxGame Developmentcli-anything-sboxSource 2 game engine - direct file I/O on .scene/.prefab/.vmat/.vsnd JSON✅ 244
🐞 LLDBNative Debuggingcli-anything-lldbLLDB Python API✅ 27
🟩 Nsight Graphics CLIGPU Debugging & Profilingcli-anything-nsight-graphicsOfficial ngfx / ngfx-capture orchestration + GPU Trace summary + replay analysis✅ 51
🔍 ExaAI-Native Web Searchcli-anything-exaexa-py SDK✅ 40
📈 Unreal InsightsPerformance Profilingcli-anything-unrealinsightsBackground trace sessions + engine-matched UnrealInsights build + headless export✅ 50
☁️ CloudAnalyzerPoint cloud / trajectory QAcli-anything-cloudanalyzerCloudAnalyzer (Python API)✅ 14
🗺️ QGISGeospatial Analysis & Mappingcli-anything-qgisPyQGIS + qgis_process✅ 22
🔩 3MFMesh geometry editor for 3D printingcli-anything-3mfnumpy, scipy, trimesh✅ 50
🗄️ TigrisObject Storage (S3-compatible, global, no egress) — buckets, objects, snapshots, IAM, access keyscli-anything-tigrissubprocess wrapping the official tigris CLINew
Total✅ 2,461
+ +> **100% pass rate** across all 2,461 tests — 1,732 unit tests + 579 end-to-end tests + 19 Node.js tests. + +--- + +## 📊 Test Results + +Each CLI harness undergoes rigorous multi-layered testing to ensure production reliability: + +| Layer | What it tests | Example | +|-------|---------------|---------| +| **Unit tests** | Every core function in isolation with synthetic data | `test_core.py` — project creation, layer ops, filter params | +| **E2E tests (native)** | Project file generation pipeline | Valid ODF ZIP structure, correct MLT XML, SVG well-formedness | +| **E2E tests (true backend)** | Real software invocation + output verification | LibreOffice → PDF with `%PDF-` magic bytes, Blender → rendered PNG | +| **CLI subprocess tests** | Installed command via `subprocess.run` | `cli-anything-gimp --json project new` → valid JSON output | + +``` +================================ Test Summary ================================ +gimp 107 passed ✅ (64 unit + 43 e2e) +blender 208 passed ✅ (150 unit + 58 e2e) +inkscape 202 passed ✅ (148 unit + 54 e2e) +audacity 161 passed ✅ (107 unit + 54 e2e) +wavetone 30 passed ✅ (24 unit + 6 default e2e, 2 backend-gated e2e skipped; 32 passed opt-in) +libreoffice 158 passed ✅ (89 unit + 69 e2e) +mubu 96 passed ✅ (85 unit + 11 e2e) +obs-studio 153 passed ✅ (116 unit + 37 e2e) +nslogger 139 passed ✅ (97 unit + 42 e2e) +kdenlive 155 passed ✅ (111 unit + 44 e2e) +shotcut 154 passed ✅ (110 unit + 44 e2e) +zoom 22 passed ✅ (22 unit + 0 e2e) +drawio 138 passed ✅ (116 unit + 22 e2e) +eth2-quickstart 18 passed ✅ (18 unit + 3 e2e skipped) +mermaid 10 passed ✅ (5 unit + 5 e2e) +anygen 50 passed ✅ (40 unit + 10 e2e) +notebooklm 21 passed ✅ (21 unit + 0 e2e) +comfyui 70 passed ✅ (60 unit + 10 e2e) +adguardhome 36 passed ✅ (24 unit + 12 e2e) +ollama 98 passed ✅ (87 unit + 11 e2e) +sketch 19 passed ✅ (19 jest, Node.js) +renderdoc 59 passed ✅ (45 unit + 14 e2e) +cloudcompare 88 passed ✅ (49 unit + 39 e2e) +openscreen 101 passed ✅ (78 unit + 23 e2e) +lldb 27 passed ✅ (23 unit + 4 e2e) +nsight-graphics 51 passed ✅ (46 unit/CLI + 5 local e2e) +unrealinsights 50 passed ✅ (49 unit + 1 e2e, 9 backend-gated e2e skipped) +cloudanalyzer 14 passed ✅ (7 unit + 7 e2e) +3mf 50 passed ✅ (50 unit) +joplin 134 passed ✅ (107 unit + 27 e2e, 1 skipped on Windows) +────────────────────────────────────────────────────────────────────────────── +TOTAL 2,461 passed ✅ 100% pass rate +``` + +--- + +## 🏗️ CLI-Anything's Architecture + +

+ CLI-Anything Architecture +

+ +### 🎯 Core Design Principles + +1. **Authentic Software Integration** — The CLI generates valid project files (ODF, MLT XML, SVG) and delegates to real applications for rendering. **We build structured interfaces TO software, not replacements**. + +2. **Flexible Interaction Models** — Every CLI operates in dual modes: stateful REPL for interactive agent sessions + subcommand interface for scripting/pipelines. **Run bare command → enter REPL mode**. + +3. **Consistent User Experience** — All generated CLIs share unified REPL interface (repl_skin.py) with branded banners, styled prompts, command history, progress indicators, and standardized formatting. + +4. **Agent-Native Design** — Built-in --json flag on every command delivers structured data for machine consumption, while human-readable tables serve interactive use. **Agents discover capabilities via standard --help and which commands**. + +5. **Zero Compromise Dependencies** — Real software is a hard requirement — no fallbacks, no graceful degradation. **Tests fail (not skip) when backends are missing, ensuring authentic functionality**. + +--- + +## 📂 Project Structure + +``` +cli-anything/ +├── 📄 README.md # You are here +├── 📁 assets/ # Images and media +│ ├── icon.png # Project icon +│ └── teaser.png # Teaser figure +│ +├── 🔌 cli-anything-plugin/ # The Claude Code plugin +│ ├── HARNESS.md # Methodology SOP (source of truth) +│ ├── README.md # Plugin documentation +│ ├── QUICKSTART.md # 5-minute getting started +│ ├── PUBLISHING.md # Distribution guide +│ ├── repl_skin.py # Unified REPL interface +│ ├── commands/ # Plugin command definitions +│ │ ├── cli-anything.md # Main build command +│ │ ├── refine.md # Expand existing harness coverage +│ │ ├── test.md # Test runner +│ │ └── validate.md # Standards validation +│ └── scripts/ +│ └── setup-cli-anything.sh # Setup script +│ +├── 🤖 codex-skill/ # Self-contained Codex skill installer +│ ├── SKILL.md # Codex workflow entry point +│ ├── agents/ # Codex UI metadata +│ ├── scripts/ # Bash and PowerShell installers +│ └── tests/ # Installer resource-sync regression test +├── 🧭 hermes-skill/ # Hermes Agent skill entry point +├── 🧠 reasonix-skill/ # Reasonix skill entry point +├── 🎨 gimp/agent-harness/ # GIMP CLI (107 tests) +├── 🧊 blender/agent-harness/ # Blender CLI (208 tests) +├── ✏️ inkscape/agent-harness/ # Inkscape CLI (202 tests) +├── 🎵 audacity/agent-harness/ # Audacity CLI (161 tests) +├── wavetone/agent-harness/ # WaveTone CLI (32 tests: 30 default + 2 backend-gated e2e) +├── 🌐 browser/agent-harness/ # Browser CLI (DOMShell MCP, new) +├── 🌐 web-yu-pri/agent-harness/ # Japan Post Web Yu-pri CLI (new) +├── 📄 libreoffice/agent-harness/ # LibreOffice CLI (158 tests) +├── 🧹 openrefine/agent-harness/ # OpenRefine CLI (76 tests: 64 unit + 12 real backend e2e) +├── 📧 mailchimp/agent-harness/ # Mailchimp Marketing API CLI (303 commands, 36 unit tests) +├── 📚 zotero/agent-harness/ # Zotero CLI (new, write import support) +├── 📖 calibre/agent-harness/ # Calibre CLI (58 tests: 38 unit + 20 E2E) +├── 📓 joplin/agent-harness/ # Joplin CLI (134 tests: 107 unit + 27 e2e) +├── 📝 mubu/agent-harness/ # Mubu CLI (96 tests) +├── 📹 obs-studio/agent-harness/ # OBS Studio CLI (153 tests) +├── 📱 nslogger/agent-harness/ # NSLogger CLI (139 tests) +├── 🎞️ kdenlive/agent-harness/ # Kdenlive CLI (155 tests) +├── 🎬 shotcut/agent-harness/ # Shotcut CLI (154 tests) +├── 📞 zoom/agent-harness/ # Zoom CLI (22 tests) +├── 🎵 musescore/agent-harness/ # MuseScore CLI (56 tests) +├── 📐 drawio/agent-harness/ # Draw.io CLI (138 tests) +├── 🧪 eez-studio/agent-harness/ # EEZ Studio CLI (project, LVGL, SCPI automation) +├── ⛓️ eth2-quickstart/agent-harness/ # ETH2 QuickStart CLI (18 unit, 3 e2e skipped) +├── 🧜 mermaid/agent-harness/ # Mermaid Live Editor CLI (10 tests) +├── ✨ anygen/agent-harness/ # AnyGen CLI (50 tests) +├── 🖼️ comfyui/agent-harness/ # ComfyUI CLI (70 tests) +├── 🧠 notebooklm/agent-harness/ # NotebookLM CLI (experimental, 21 tests) +├── 🧩 dify-workflow/agent-harness/ # Dify Workflow CLI wrapper (11 tests) +├── 🛡️ adguardhome/agent-harness/ # AdGuard Home CLI (36 tests) +├── 🦙 ollama/agent-harness/ # Ollama CLI (98 tests) +├── 🎮 godot/agent-harness/ # Godot Engine CLI (24 tests) +├── 📦 sbox/agent-harness/ # s&box CLI (244 tests: 157 unit + 17 orchestrator + 50 e2e + 20 exit-code) +├── 🎨 sketch/agent-harness/ # Sketch CLI (19 tests, Node.js) +├── 🔬 renderdoc/agent-harness/ # RenderDoc CLI (59 tests) +├── 🟩 nsight-graphics/agent-harness/ # Nsight Graphics CLI (51 tests) +├── 🐞 lldb/agent-harness/ # LLDB CLI (27 tests) +├── 📈 unrealinsights/agent-harness/ # Unreal Insights CLI (50 tests) +├── 🎬 videocaptioner/agent-harness/ # VideoCaptioner CLI (26 tests) +├── 🎬 openscreen/agent-harness/ # Openscreen CLI — screen recording editor (101 tests) +├── ☁️ cloudcompare/agent-harness/ # CloudCompare CLI (88 tests) +├── 🔍 exa/agent-harness/ # Exa CLI (40 tests) +└── ⛅ cloudanalyzer/agent-harness/ # CloudAnalyzer CLI (14 tests) +└── 🔩 3MF/agent-harness/ # 3MF Mesh Editor CLI (50+ tests) +``` + +Each `agent-harness/` contains an installable Python package under `cli_anything./` with Click CLI, core modules, utils (including `repl_skin.py` and backend wrapper), and comprehensive tests. + +--- + +## 🎯 Plugin Commands + +| Command | Description | +|---------|-------------| +| `/cli-anything ` | Build complete CLI harness — all 7 phases | +| `/cli-anything:refine [focus]` | Refine an existing harness — expand coverage with gap analysis | +| `/cli-anything:test ` | Run tests and update TEST.md with results | +| `/cli-anything:validate ` | Validate against HARNESS.md standards | + +### Examples + +```bash +# Build a complete CLI for GIMP from local source +/cli-anything /home/user/gimp + +# Build from a GitHub repo +/cli-anything https://github.com/blender/blender + +# Refine an existing harness — broad gap analysis +/cli-anything:refine /home/user/gimp + +# Refine with a specific focus area +/cli-anything:refine /home/user/shotcut "vid-in-vid and picture-in-picture compositing" + +# Run tests and update TEST.md +/cli-anything:test /home/user/inkscape + +# Validate against HARNESS.md standards +/cli-anything:validate /home/user/audacity +``` + +--- + +## 🎮 Demo: Using a Generated CLI + +Here's what an agent can do with `cli-anything-libreoffice`: + +```bash +# Create a new Writer document +$ cli-anything-libreoffice document new -o report.json --type writer +✓ Created Writer document: report.json + +# Add content +$ cli-anything-libreoffice --project report.json writer add-heading -t "Q1 Report" --level 1 +✓ Added heading: "Q1 Report" + +$ cli-anything-libreoffice --project report.json writer add-table --rows 4 --cols 3 +✓ Added 4×3 table + +# Export to real PDF via LibreOffice headless +$ cli-anything-libreoffice --project report.json export render output.pdf -p pdf --overwrite +✓ Exported: output.pdf (42,831 bytes) via libreoffice-headless + +# JSON mode for agent consumption +$ cli-anything-libreoffice --json document info --project report.json +{ + "name": "Q1 Report", + "type": "writer", + "pages": 1, + "elements": 2, + "modified": true +} +``` + +### REPL Mode + +``` +$ cli-anything-blender +╔══════════════════════════════════════════╗ +║ cli-anything-blender v1.0.0 ║ +║ Blender CLI for AI Agents ║ +╚══════════════════════════════════════════╝ + +blender> scene new --name ProductShot +✓ Created scene: ProductShot + +blender[ProductShot]> object add-mesh --type cube --location 0 0 1 +✓ Added mesh: Cube at (0, 0, 1) + +blender[ProductShot]*> render execute --output render.png --engine CYCLES +✓ Rendered: render.png (1920×1080, 2.3 MB) via blender --background + +blender[ProductShot]> exit +Goodbye! 👋 +``` + +--- + +## 📖 The Standard Playbook: HARNESS.md + +HARNESS.md is our definitive SOP for making any software agent-accessible via automated CLI generation. + +It encodes proven patterns and methodologies refined through automated generation processes. + +The playbook distills key insights from successfully building all 18 diverse, production-ready harnesses. + +### Critical Lessons + +| Lesson | Description | +|--------|-------------| +| **Use the real software** | The CLI MUST call the actual application for rendering. No Pillow replacements for GIMP, no custom renderers for Blender. Generate valid project files → invoke the real backend. | +| **The Rendering Gap** | GUI apps apply effects at render time. If your CLI manipulates project files but uses a naive export tool, effects get silently dropped. Solution: native renderer → filter translation → render script. | +| **Filter Translation** | When mapping effects between formats (MLT → ffmpeg), watch for duplicate filter merging, interleaved stream ordering, parameter space differences, and unmappable effects. | +| **Timecode Precision** | Non-integer frame rates (29.97fps) cause cumulative rounding. Use `round()` not `int()`, integer arithmetic for display, and ±1 frame tolerance in tests. | +| **Output Verification** | Never trust that export worked because it exited 0. Verify: magic bytes, ZIP/OOXML structure, pixel analysis, audio RMS levels, duration checks. | + +> See the full methodology: [`cli-anything-plugin/HARNESS.md`](cli-anything-plugin/HARNESS.md) + +--- + +## 📦 Installation & Usage + +### For CLI-Hub Users + +```bash +# Install the package manager +pip install cli-anything-hub + +# Browse, search, inspect, and install CLIs +cli-hub list +cli-hub search +cli-hub info +cli-hub install + +# Manage installed CLIs +cli-hub update +cli-hub uninstall +cli-hub launch [args...] +``` + +### For Plugin Users (Claude Code) + +```bash +# Add marketplace & install (recommended) +/plugin marketplace add HKUDS/CLI-Anything +/plugin install cli-anything + +# Build a CLI for any software with a codebase +/cli-anything +``` + +### For Generated CLIs + +```bash +# Install any generated CLI +cd /agent-harness +pip install -e . + +# Verify +which cli-anything- + +# Use +cli-anything- --help +cli-anything- # enters REPL +cli-anything- --json # JSON output for agents +``` + +### Running Tests + +```bash +# Run tests for a specific CLI +cd /agent-harness +python3 -m pytest cli_anything//tests/ -v + +# Force-installed mode (recommended for validation) +CLI_ANYTHING_FORCE_INSTALLED=1 python3 -m pytest cli_anything//tests/ -v -s +``` + +--- + +## 🤝 Contributing + +We welcome contributions! CLI-Anything is designed to be extensible: + +- **New software targets** — Use the plugin to generate a CLI for any software with a codebase, then submit your harness via [`cli-anything-plugin/PUBLISHING.md`](cli-anything-plugin/PUBLISHING.md). +- **Methodology improvements** — PRs to `HARNESS.md` that encode new lessons learned +- **Plugin enhancements** — New commands, phase improvements, better validation +- **Test coverage** — More E2E scenarios, edge cases, workflow tests + +### Limitations + +- **Requires strong foundation models** — CLI-Anything relies on frontier-class models (e.g., Claude Opus 4.6, Claude Sonnet 4.6, GPT-5.4) for reliable harness generation. Weaker or smaller models may produce incomplete or incorrect CLIs that require significant manual correction. +- **Relies on available source code** — The 7-phase pipeline analyzes and generates from source code. When the target software only provides compiled binaries that require decompilation, harness quality and coverage will degrade substantially. +- **May require iterative refinement** — A single `/cli-anything` run may not fully cover all capabilities. Running `/refine` one or more times is often needed to push the CLI's performance and coverage to production quality. + +### Roadmap + +- [ ] Support for more application categories (CAD, DAW, IDE, EDA, scientific tools) +- [ ] Benchmark suite for agent task completion rates +- [ ] Community-contributed CLI harnesses for internal/custom software +- [ ] Integration with additional agent frameworks beyond Claude Code +- [ ] Support packaging APIs for closed-source software and web services into CLIs +- [x] Produce SKILL.md alongside the CLI for agent skill discovery and orchestration + +--- + +## 📖 Documentation + +| Document | Description | +|----------|-------------| +| [`cli-anything-plugin/HARNESS.md`](cli-anything-plugin/HARNESS.md) | The methodology SOP — single source of truth | +| [`cli-anything-plugin/README.md`](cli-anything-plugin/README.md) | Plugin documentation — commands, options, phases | +| [`cli-anything-plugin/QUICKSTART.md`](cli-anything-plugin/QUICKSTART.md) | 5-minute getting started guide | +| [`cli-anything-plugin/PUBLISHING.md`](cli-anything-plugin/PUBLISHING.md) | Distribution and publishing guide | + +Each generated harness also includes: +- `.md` — Architecture SOP specific to that application +- `tests/TEST.md` — Test plan and results documentation + +--- + +## ⭐ Star History + +If CLI-Anything helps make your software Agent-native, give us a star! ⭐ + + + +--- + +## 📚 Citation + +If you find CLI-Anything useful, please cite our technical report: + +```bibtex +@misc{yang2026clianythingagentnativecomputeruse, + title={CLI-Anything: Towards Agent-Native Computer Use}, + author={Yuhao Yang and Tianyu Fan and Chao Huang}, + year={2026}, + eprint={2606.03854}, + archivePrefix={arXiv}, + primaryClass={cs.HC}, + url={https://arxiv.org/abs/2606.03854}, +} +``` + +--- + +## 📄 License + +Apache License 2.0 — free to use, modify, and distribute. + +--- + +
+ +**CLI-Anything** — *Make any software with a codebase Agent-native.* + +A methodology for the age of AI agents | 18 professional software demos | 2,280 passing tests + +
+ +CLI-Anything Icon + +
+ +

+ Thanks for visiting ✨ CLI-Anything!

+ Views +

diff --git a/README_CN.md b/README_CN.md new file mode 100644 index 0000000..94e7548 --- /dev/null +++ b/README_CN.md @@ -0,0 +1,1049 @@ +

  CLI-Anything: 让所有软件都能被 Agent 驱动

+ +

+ 今天的软件为人而生👨‍💻,明天的用户是 Agent🤖
+CLI-Anything:连接 AI Agent 与全世界软件的桥梁

+

+ +**🌐 [CLI-Hub](https://hkuds.github.io/CLI-Anything/)**:在 **[CLI-Hub](https://hkuds.github.io/CLI-Anything/)** 探索社区已构建的所有 CLI,一条命令即可安装。想贡献你的 CLI?[提交 PR](https://github.com/HKUDS/CLI-Anything/blob/main/CONTRIBUTING.md) — Hub 会即时更新。 + +

+ Quick Start + Demos + Tests + Tech Report + License +

+ +

+ Python + Click + Pytest + Coverage + Output + Feishu +WeChat +

+ +**一行命令**,让任意软件接入 OpenClaw、nanobot、Cursor、Claude Code 等 Agent 框架。 + +

+ CLI-Anything typing demo +

+ +

+ CLI-Anything Teaser +

+ +--- + +## 🤔 为什么是 CLI? + +CLI 是人类和 AI Agent 共通的万能接口: + +• **结构化、可组合** - 文本命令天然匹配 LLM 的输入格式,可自由串联成复杂工作流 + +• **轻量且通用** - 几乎零开销,跨平台运行,不依赖额外环境 + +• **自描述** - 一个 `--help` 就能让 Agent 自动发现所有功能 + +• **久经验证** - Claude Code 每天通过 CLI 执行数以千计的真实任务 + +• **Agent 友好** - 结构化 JSON 输出,Agent 无需任何额外解析 + +• **确定且可靠** - 输出稳定一致,Agent 行为可预测 + +## 🚀 快速上手 + +### 环境要求 + +- **Python 3.10+** +- 目标软件已安装(如 GIMP、Blender、LibreOffice 或你自己的应用) +- 支持的 AI 编程工具之一:[Claude Code](#-claude-code) | [OpenClaw](#-openclaw) | [OpenCode](#-opencode) | [Codex](#-codex) | [Qodercli](#-qodercli) | [GitHub Copilot CLI](#-github-copilot-cli) | [更多平台](#-更多平台即将支持) + +### 选择你的平台 + +
+

⚡ Claude Code

+ +**第一步:添加插件市场** + +CLI-Anything 以 Claude Code 插件市场的形式托管在 GitHub 上。 + +```bash +# 添加 CLI-Anything 插件市场 +/plugin marketplace add HKUDS/CLI-Anything +``` + +**第二步:安装插件** + +```bash +# 从市场安装 cli-anything 插件 +/plugin install cli-anything +``` + +搞定。插件已经在你的 Claude Code 会话中可用了。 + +**Windows 注意:** Claude Code 通过 `bash` 执行命令。Windows 下请安装 Git for Windows(包含 `bash` 和 `cygpath`) +或使用 WSL,否则可能出现 `cygpath: command not found`。 + +**第三步:一行命令生成 CLI** + +```bash +# /cli-anything <软件路径或仓库地址> +# 为 GIMP 生成完整的 CLI(7 个阶段全自动) +/cli-anything ./gimp + +# 兼容写法(旧版本 Claude Code 可重试) +# /cli-anything:cli-anything ./gimp +``` + +Claude Code 不同版本的命令兼容说明: +- 优先使用 `/cli-anything` 作为主入口。 +- 在已**确认插件已安装并加载**的情况下,若旧版本的 Claude Code 不识别 `/cli-anything`,可尝试兼容写法 `/cli-anything:cli-anything`。 +- 其他辅助命令保持 `:子命令` 形式(例如 `/cli-anything:refine`)。 + +如果出现 `Unknown skill: cli-anything`,两种写法都引用同一个 skill 名称,切换写法无法解决,请优先排查插件是否已安装/加载: +1. 重新加载插件命令:`/reload-plugins` +2. 验证插件是否已加载:`/help cli-anything`(能看到 CLI-Anything 帮助即表示已加载) +3. 如仍未识别,重新从市场安装: + - `/plugin marketplace add HKUDS/CLI-Anything` + - `/plugin install cli-anything` +4. 确认插件可用后,再重试入口命令: + - 推荐:`/cli-anything ./gimp` + - 仅旧版本:`/cli-anything:cli-anything ./gimp` + +完整流水线自动执行: +1. 🔍 **分析** — 扫描源码,将 GUI 操作映射到 API +2. 📐 **设计** — 规划命令分组、状态模型、输出格式 +3. 🔨 **实现** — 构建 Click CLI,包含 REPL、JSON 输出、撤销/重做 +4. 📋 **规划测试** — 生成 TEST.md,涵盖单元测试和端到端测试计划 +5. 🧪 **编写测试** — 实现完整测试套件 +6. 📝 **文档** — 更新 TEST.md,写入测试结果 +7. 📦 **发布** — 生成 `setup.py`,安装到 PATH + +**第四步(可选):优化和扩展 CLI** + +初始构建完成后,你可以迭代优化 CLI,扩展覆盖面并补充缺失的功能: + +```bash +# 全面优化 — Agent 分析所有功能的覆盖差距 +/cli-anything:refine ./gimp + +# 定向优化 — 指定特定功能领域 +/cli-anything:refine ./gimp "我需要更多图像批处理和滤镜相关的 CLI" +``` + +优化命令会对软件的完整功能与当前 CLI 覆盖范围进行差距分析,然后为识别到的差距实现新命令、测试和文档。你可以多次运行该命令,逐步扩大功能覆盖范围 — 每次运行都是增量的、非破坏性的。 + +
+备选方案:手动安装 + +如果你不想用插件市场: + +```bash +# 克隆仓库 +git clone https://github.com/HKUDS/CLI-Anything.git + +# 复制插件到 Claude Code 插件目录 +cp -r CLI-Anything/cli-anything-plugin ~/.claude/plugins/cli-anything + +# 重新加载插件 +/reload-plugins +``` + +
+ +
+ +
+

⚡ OpenCode (实验性支持)

+ +**第一步:安装命令** + +**注意:** 请升级到最新 OpenCode,旧版本可能使用不同的命令目录路径。 + +将 CLI-Anything 命令**和** `HARNESS.md` 复制到 OpenCode 命令目录: + +```bash +# 克隆仓库 +git clone https://github.com/HKUDS/CLI-Anything.git + +# 全局安装(所有项目可用) +cp CLI-Anything/opencode-commands/*.md ~/.config/opencode/commands/ +cp CLI-Anything/cli-anything-plugin/HARNESS.md ~/.config/opencode/commands/ + +# 或项目级安装 +cp CLI-Anything/opencode-commands/*.md .opencode/commands/ +cp CLI-Anything/cli-anything-plugin/HARNESS.md .opencode/commands/ +``` + +> **注意:** `HARNESS.md` 是所有命令引用的方法论规范,必须和命令文件放在同一目录下。 + +安装后获得 5 个斜杠命令:`/cli-anything`、`/cli-anything-refine`、`/cli-anything-test`、`/cli-anything-validate` 和 `/cli-anything-list`。 + +**第二步:一行命令生成 CLI** + +```bash +# 为 GIMP 生成完整的 CLI(7 个阶段全自动) +/cli-anything ./gimp + +# 从 GitHub 仓库构建 +/cli-anything https://github.com/blender/blender +``` + +命令以子任务方式运行,遵循与 Claude Code 相同的 7 阶段方法论。 + +**第三步(可选):优化和扩展 CLI** + +```bash +# 全面优化 — Agent 分析所有功能的覆盖差距 +/cli-anything-refine ./gimp + +# 定向优化 — 指定特定功能领域 +/cli-anything-refine ./gimp "批处理和滤镜" +``` + +
+ +
+ +

⚡ Qodercli 社区贡献

+ +**第一步:注册插件** + +```bash +git clone https://github.com/HKUDS/CLI-Anything.git +bash CLI-Anything/qoder-plugin/setup-qodercli.sh +``` + +脚本会将 cli-anything 插件注册到 `~/.qoder.json`。注册后开启新的 Qodercli 会话即可使用。 + +**第二步:在 Qodercli 中使用 CLI-Anything** + +```bash +/cli-anything:cli-anything ./gimp +/cli-anything:refine ./gimp "批处理和滤镜" +/cli-anything:validate ./gimp +``` +
+ +
+ +

⚡ OpenClaw

+ +**第一步:安装 Skill** + +CLI-Anything 提供了原生的 OpenClaw `SKILL.md` 文件。请将其复制到你的 OpenClaw 技能目录: + +```bash +# Clone the repo +git clone https://github.com/HKUDS/CLI-Anything.git + +# Install to the global skills folder +mkdir -p ~/.openclaw/skills/cli-anything +cp CLI-Anything/openclaw-skill/SKILL.md ~/.openclaw/skills/cli-anything/SKILL.md +``` + +**第二步:构建 CLI** + +安装完成后,你就可以在 OpenClaw 中直接调用: + +`@cli-anything build a CLI for ./gimp` + +该技能采用了与 Claude Code 和 OpenCode 一致的 7 步构建流程。 + +
+ +
+ +

⚡ Codex 实验性 社区贡献

+ +**第一步:安装 Skill** + +运行仓库内置的安装脚本: + +```bash +# 克隆仓库 +git clone https://github.com/HKUDS/CLI-Anything.git + +# 安装 skill +bash CLI-Anything/codex-skill/scripts/install.sh +``` + +在 Windows PowerShell 中,可以使用: + +```powershell +.\CLI-Anything\codex-skill\scripts\install.ps1 +``` + +脚本会把 skill 安装到 `$CODEX_HOME/skills/cli-anything`;如果没有设置 `CODEX_HOME`,则默认安装到 `~/.codex/skills/cli-anything`。 +安装器还会把权威版本的 `HARNESS.md`、命令规范、按需指南、可复用辅助脚本、skill 模板和 preview 协议复制到已安装 skill 的 `references/` 和 `scripts/` 目录。这样 Codex skill 可以独立工作,同时仍以 `cli-anything-plugin/` 作为唯一权威来源。 + +安装后重启 Codex,让它重新发现这个 skill。 + +**第二步:在 Codex 里使用 CLI-Anything** + +直接用自然语言描述任务,例如: + +```text +Use CLI-Anything to build a harness for ./gimp +Use CLI-Anything to refine ./shotcut for picture-in-picture workflows +Use CLI-Anything to validate ./libreoffice +Use CLI-Anything to list generated harnesses under the current directory +``` + +这个 Codex skill 复用了 Claude Code 插件和 OpenCode 命令所使用的同一套方法论, +不会改变生成出来的 Python harness 结构。 + +可以运行下面的命令,验证 Codex 安装包是否完整: + +```bash +bash CLI-Anything/codex-skill/tests/test_install.sh +``` + +
+ +
+ +

⚡ GitHub Copilot CLI 社区贡献

+ +**第一步:安装插件** + +```bash +git clone https://github.com/HKUDS/CLI-Anything.git +cd CLI-Anything +copilot plugin install ./cli-anything-plugin +``` + +这将 CLI-Anything 插件安装到 GitHub Copilot CLI 中。插件应该已经在你的 GitHub Copilot CLI 会话中可用了。 + +**第二步:在 GitHub Copilot CLI 中使用 CLI-Anything** + +```bash +/cli-anything:cli-anything ./gimp +/cli-anything:refine ./gimp "批处理和滤镜" +/cli-anything:validate ./gimp +``` + +
+ +
+

🔮 更多平台(即将支持)

+ +CLI-Anything 的设计是平台无关的,计划支持更多 AI 编程工具: + +- **Codex** — 已通过 `codex-skill/` 提供接入 +- **Cursor** — 即将支持 +- **Windsurf** — 即将支持 +- **你喜欢的工具** — 欢迎贡献!参考 `opencode-commands/` 目录的实现。 + +
+ +### 开始使用生成的 CLI + +无论你用哪个平台构建,生成的 CLI 使用方式完全一样: + +```bash +# 安装到 PATH +cd gimp/agent-harness && pip install -e . + +# 随处可用 +cli-anything-gimp --help +cli-anything-gimp project new --width 1920 --height 1080 -o poster.json +cli-anything-gimp --json layer add -n "Background" --type solid --color "#1a1a2e" + +# 进入交互式 REPL +cli-anything-gimp +``` + +--- + +## 💡 CLI-Anything 的愿景:构建 Agent 原生的软件生态 + +• 🌐 **无门槛接入** - 任何软件都能通过结构化 CLI 即刻被 Agent 操控。 + +• 🔗 **无缝集成** - 不需要专门的 API、不需要操控 GUI、不需要重构代码,也不需要复杂的适配层。 + +• 🚀 **面向未来** - 一条命令,就能把为人类设计的软件变成 Agent 的原生工具。 + +--- + +## 🔧 适用场景 + +| 类别 | 如何接入 Agent | 典型软件 | +|------|--------------|---------| +| **📂 GitHub 开源项目** | 通过自动生成 CLI,将任意开源项目转化为 Agent 可控工具 | VSCodium、WordPress、Calibre、Zotero、Joplin、Logseq、Penpot、Super Productivity | +| **🤖 AI/ML 平台** | 用结构化命令驱动模型训练、推理流水线和超参搜索 | Stable Diffusion WebUI、ComfyUI、InvokeAI、Text-generation-webui、Open WebUI、Fooocus、Kohya_ss、AnythingLLM、SillyTavern | +| **📊 数据与分析** | 以编程方式完成数据处理、可视化和统计分析工作流 | JupyterLab、Apache Superset、Metabase、Redash、DBeaver、KNIME、Orange、OpenSearch Dashboards、Lightdash | +| **💻 开发工具** | 通过 CLI 串联代码编辑、构建、测试与部署流程 | Jenkins、Gitea、Hoppscotch、Portainer、pgAdmin、SonarQube、ArgoCD、OpenLens、Insomnia、Beekeeper Studio | +| **🎨 创意与媒体** | 以编程方式控制内容创作、编辑和渲染工作流 | Blender、GIMP、OBS Studio、Audacity、WaveTone、Krita、Kdenlive、Shotcut、Inkscape、Darktable、LMMS、Ardour | +| **📐 图表与可视化** | 以编程方式创建和操作流程图、架构图、ER 图等各类图表 | Draw.io (diagrams.net)、Mermaid、PlantUML、Excalidraw、yEd | +| **🔬 科学计算** | 自动化科研工作流、仿真模拟和复杂计算 | ImageJ、FreeCAD、QGIS、ParaView、Gephi、LibreCAD、Stellarium、KiCad、JASP、Jamovi | +| **🏢 企业与办公** | 将商业应用和生产力工具转化为 Agent 可访问的系统 | NextCloud、GitLab、Grafana、Mattermost、LibreOffice、AppFlowy、NocoDB、Odoo (Community)、Plane、ERPNext | +| **📞 通信与协作** | 通过结构化 CLI 自动化会议调度、参会人管理、录制获取和报告生成 | Zoom、Jitsi Meet、BigBlueButton、Mattermost | + +--- + +## CLI-Anything 的核心优势 + +### Agent 与软件之间的鸿沟 + +AI Agent 推理能力很强,但操控真实专业软件的能力很弱。现有方案要么是脆弱的 GUI 自动化,要么是覆盖面有限的 API,要么是阉割了 90% 功能的重新实现。 + +**CLI-Anything 的解法**:把任何专业软件变成 Agent 原生工具,功能一个不少。 + +| **现有痛点** | **CLI-Anything 怎么解** | +|------------|----------------------| +| 🤖 "AI 用不了真正的专业工具" | 直接对接真实软件后端(Blender、LibreOffice、FFmpeg)—— 完整的专业能力,零妥协 | +| 💸 "GUI 自动化三天两头崩" | 告别截图、点击和 RPA 的脆弱性,纯命令行操控,结构化接口 | +| 📊 "Agent 需要结构化数据" | 内置 JSON 输出供 Agent 直接消费,同时保留可读格式方便调试 | +| 🔧 "定制集成太贵了" | 一个插件就能为任意代码库自动生成 CLI,经过验证的 7 阶段流水线 | +| ⚡ "原型和生产之间差十万八千里" | 1,774+ 测试用例,全部在真实软件上验证通过,覆盖 13 款主流应用 | + +--- + +## 🎯 CLI-Anything 能做什么? + + + + + + + +
+ +### 🛠️ 让 Agent 接管你的工作流 + +不管是专业场景还是日常事务 —— 把代码库扔给 `/cli-anything` 就行。GIMP、Blender、Shotcut 搞创作,LibreOffice、OBS Studio 干日常。没有源码?找个开源替代品,照样能用。你会立刻得到一套 Agent 可以直接调用的完整 CLI。 + + + +### 🔗 把散装 API 统一成一个 CLI + +受够了一堆零碎的 Web 服务 API?把文档或 SDK 手册喂给 `/cli-anything`,你的 Agent 就能拿到一个**有状态的、功能完整的 CLI**,把那些零散的接口整合成逻辑清晰的命令组。一个工具可替代几十个裸 API 调用 —— 能力更强,token 更省。 + + + +### 🚀 取代 GUI Agent,或让它更强 + +CLI-Anything 可直接**替代基于 GUI 的 Agent 方案** —— 无需截图,也无需繁琐地点击像素操作。更实用的是:只要你对 GUI 软件执行过 `/cli-anything` 命令,就能**全自动生成 Agent 任务、评测器及 Benchmark** —— 全程采用纯代码与终端操作,支持迭代优化,显著提升效率。 + +
+ +--- + +## ✨ ⚙️ CLI-Anything 的工作方式 + + + + + + + + + + + + + + +
+ +### 🏗️ 全自动 7 阶段流水线 + +从代码分析到发布上线 —— 插件自动完成架构设计、代码实现、测试规划、测试编写和文档生成,全程无需人工介入。 + + + +### 🎯 真实软件集成 + +直接调用真实应用进行渲染。LibreOffice 生成 PDF,Blender 渲染 3D 场景,Audacity 通过 sox 处理音频。**零妥协**,**零玩具实现**。 + +
+ +### 🔁 智能会话管理 + +持久化项目状态,支持撤销/重做,加上统一的 REPL 交互界面(ReplSkin),所有 CLI 的使用体验保持一致。 + + + +### 📦 零配置安装 + +`pip install -e .` 即可将 cli-anything-<软件名> 装到 PATH。Agent 通过标准的 `which` 命令发现工具,不需要额外配置。 + +
+ +### 🧪 生产级测试 + +多层验证:使用合成数据的单元测试、使用真实文件和软件的端到端测试,外加已安装命令的 CLI 子进程验证。 + + + +### 🐍 干净的包架构 + +所有 CLI 统一在 cli_anything.* 命名空间下 —— 无冲突、可 pip 安装、命名规范统一:cli-anything-gimp、cli-anything-blender 等。 + +
+ +--- + +## 🎬 实测展示 + +### 🎯 通用性 + +CLI-Anything 适用于任何有代码库的软件 —— 不限领域,不限架构。 + +### 🏭 专业级测试 + +在 13 款复杂应用上进行了实测,涵盖创意、生产力、通信、图表、原生调试、GPU 分析和 AI 内容生成领域 —— 这些软件此前对 AI Agent 来说几乎不可触及。 + +### 🎨 覆盖多元领域 + +从创意工作流(图像编辑、3D 建模、矢量图形)到生产工具(音频、办公、直播、视频剪辑)。 + +### ✅ 完整的 CLI 生成 + +每款软件都生成了完整的、可投产的 CLI 接口 —— 不是 demo,而是保留全部功能的完整工具接入。 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
软件领域CLI 命令后端测试
🎨 GIMP图像编辑cli-anything-gimpPillow + GEGL/Script-Fu✅ 107
🧊 Blender3D 建模与渲染cli-anything-blenderbpy (Python scripting)✅ 208
✏️ Inkscape矢量图形cli-anything-inkscapeDirect SVG/XML manipulation✅ 202
🎵 Audacity音频制作cli-anything-audacityPython wave + sox✅ 161
WaveTone音频扒谱辅助cli-anything-wavetoneJSON manifest + real WaveTone launch新增
📄 LibreOffice办公套件(Writer、Calc、Impress)cli-anything-libreofficeODF generation + headless LO✅ 158
📚 Zotero文献管理与引用cli-anything-zoteroLocal SQLite + connector + Local API新增
📹 OBS Studio直播与录制cli-anything-obs-studioJSON scene + obs-websocket✅ 153
🎞️ Kdenlive视频剪辑cli-anything-kdenliveMLT XML + melt renderer✅ 155
🎬 Shotcut视频剪辑cli-anything-shotcutDirect MLT XML + melt✅ 154
🎬 Openscreen屏幕录像编辑器cli-anything-openscreenffmpeg backend✅ 101
📞 Zoom视频会议cli-anything-zoomZoom REST API (OAuth2)✅ 22
📐 Draw.io图表绘制cli-anything-drawiomxGraph XML + draw.io CLI✅ 138
✨ AnyGenAI 内容生成cli-anything-anygenAnyGen REST API (anygen.io)✅ 50
🐞 LLDB原生调试cli-anything-lldbLLDB Python API✅ 27
🟩 Nsight Graphics CLIGPU 调试与分析cli-anything-nsight-graphics官方 ngfx / ngfx-capture 编排 + GPU Trace 摘要 + replay 分析✅ 51
📈 Unreal Insights性能分析cli-anything-unrealinsights后台 trace 会话 + 匹配版 UnrealInsights 构建 + 无头导出✅ 50
📦 s&box游戏开发cli-anything-sboxSource 2 游戏引擎 - 直接读写 .scene/.prefab/.vmat/.vsnd JSON✅ 244
🎨 SketchUI 设计sketch-clisketch-constructor (Node.js)✅ 19
🔩 3MF3D 打印网格编辑器cli-anything-3mfnumpy, scipy, trimesh✅ 50+
📓 Joplin笔记与待办cli-anything-joplinJoplin 终端 CLI 子进程✅ 134
合计✅ 1,955
+ +> 全部 1,955 项测试 **100% 通过** - 1,318 项单元测试 + 487 项端到端测试 + 19 项 Node.js 测试。 + +--- + +## 📊 测试结果 + +每个 CLI 都经过多层严格测试,确保生产可用: + +| 测试层级 | 测什么 | 示例 | +|---------|-------|------| +| **单元测试** | 每个核心函数的隔离验证,使用合成数据 | `test_core.py` — 项目创建、图层操作、滤镜参数 | +| **端到端测试(原生)** | 项目文件的完整生成流程 | ODF ZIP 结构合法性、MLT XML 正确性、SVG 格式完整性 | +| **端到端测试(真实后端)** | 调用真实软件并验证输出 | LibreOffice → 含 `%PDF-` 魔术字节的 PDF,Blender → 渲染后的 PNG | +| **CLI 子进程测试** | 通过 `subprocess.run` 调用已安装命令 | `cli-anything-gimp --json project new` → 合法 JSON 输出 | + +``` +================================ Test Summary ================================ +gimp 107 passed ✅ (64 unit + 43 e2e) +blender 208 passed ✅ (150 unit + 58 e2e) +inkscape 202 passed ✅ (148 unit + 54 e2e) +audacity 161 passed ✅ (107 unit + 54 e2e) +wavetone 30 passed ✅ (24 unit + 6 default e2e,2 backend-gated e2e skipped;32 passed opt-in) +libreoffice 158 passed ✅ (89 unit + 69 e2e) +obs-studio 153 passed ✅ (116 unit + 37 e2e) +kdenlive 155 passed ✅ (111 unit + 44 e2e) +shotcut 154 passed ✅ (110 unit + 44 e2e) +openscreen 101 passed ✅ (78 unit + 23 e2e) +zoom 22 passed ✅ (22 unit + 0 e2e) +drawio 138 passed ✅ (116 unit + 22 e2e) +anygen 50 passed ✅ (40 unit + 10 e2e) +lldb 27 passed ✅ (23 unit + 4 e2e) +nsight-graphics 51 passed ✅ (46 unit/CLI + 5 local e2e) +unrealinsights 50 passed ✅ (49 unit + 1 e2e, 9 backend-gated e2e skipped) +sketch 19 passed ✅ (19 jest, Node.js) +3mf 50 passed ✅ (50 unit) +joplin 134 passed ✅ (107 unit + 27 e2e, 1 skipped on Windows) +────────────────────────────────────────────────────────────────────────────── +TOTAL 1,955 passed ✅ 100% pass rate +``` + +--- + +## 🏗️ CLI-Anything 的架构 + +

+ CLI-Anything Architecture +

+ +### 🎯 核心设计原则 + +1. **真实软件集成** — CLI 生成合法的项目文件(ODF、MLT XML、SVG),然后交给真实应用去渲染。**我们做的是软件的结构化接口,而不是替代品**。 +2. **灵活的交互模式** — 每个 CLI 都支持两种模式:有状态的 REPL 用于 Agent 交互会话,子命令模式用于脚本和流水线。**直接运行命令即进入 REPL**。 +3. **一致的使用体验** — 所有生成的 CLI 共享统一的 REPL 界面(repl_skin.py),带有品牌横幅、风格化提示符、命令历史、进度指示器和标准化格式。 +4. **Agent 原生设计** — 每个命令内置 `--json` 参数,输出结构化数据供 Agent 消费,同时可读的表格格式服务于交互调试。**Agent 通过标准的 `--help` 和 `which` 命令发现能力**。 +5. **零妥协的依赖策略** — 真实软件是硬性要求 —— 没有兜底,没有降级。**后端缺失时测试直接失败(而非跳过),确保功能的真实性**。 + +--- + +## 📂 项目结构 + +``` +cli-anything/ +├── 📄 README.md # 英文文档 +├── 📄 README_CN.md # 中文文档(你在这里) +├── 📁 assets/ # 图片和媒体文件 +│ ├── icon.png # 项目图标 +│ └── teaser.png # 概览图 +│ +├── 🔌 cli-anything-plugin/ # Claude Code 插件 +│ ├── HARNESS.md # 方法论 SOP(唯一权威来源) +│ ├── README.md # 插件文档 +│ ├── QUICKSTART.md # 5 分钟快速上手 +│ ├── PUBLISHING.md # 分发与发布指南 +│ ├── repl_skin.py # 统一 REPL 界面 +│ ├── commands/ # 插件命令定义 +│ │ ├── cli-anything.md # 主构建命令 +│ │ ├── refine.md # 扩展已有 harness 覆盖面 +│ │ ├── test.md # 测试运行器 +│ │ ├── validate.md # 标准验证 +│ │ └── list.md # 列出所有 CLI 工具 +│ └── scripts/ +│ └── setup-cli-anything.sh # 安装脚本 +│ +├── 📋 opencode-commands/ # OpenCode 命令 +│ ├── cli-anything.md # 主构建命令 +│ ├── cli-anything-refine.md # 优化扩展命令 +│ ├── cli-anything-test.md # 测试运行器 +│ ├── cli-anything-validate.md # 标准验证 +│ └── cli-anything-list.md # 列出所有 CLI 工具 +│ +├── 🤖 codex-skill/ # 可独立安装的 Codex skill +│ ├── SKILL.md # Codex 工作流入口 +│ ├── agents/ # Codex 界面元数据 +│ ├── scripts/ # Bash 和 PowerShell 安装器 +│ └── tests/ # 安装资源同步回归测试 +├── 🎨 gimp/agent-harness/ # GIMP CLI(107 项测试) +├── 🧊 blender/agent-harness/ # Blender CLI(208 项测试) +├── ✏️ inkscape/agent-harness/ # Inkscape CLI(202 项测试) +├── 🎵 audacity/agent-harness/ # Audacity CLI(161 项测试) +├── wavetone/agent-harness/ # WaveTone CLI(32 项测试:30 default + 2 backend-gated e2e) +├── 📄 libreoffice/agent-harness/ # LibreOffice CLI(158 项测试) +├── 📚 zotero/agent-harness/ # Zotero CLI(新增,支持文献导入) +├── 📓 joplin/agent-harness/ # Joplin CLI(134 测试:107 单元 + 27 e2e) +├── 📹 obs-studio/agent-harness/ # OBS Studio CLI(153 项测试) +├── 🎞️ kdenlive/agent-harness/ # Kdenlive CLI(155 项测试) +├── 🎬 shotcut/agent-harness/ # Shotcut CLI(154 项测试) +├── 🎬 openscreen/agent-harness/ # Openscreen CLI — 屏幕录像编辑器(101 项测试) +├── 📞 zoom/agent-harness/ # Zoom CLI(22 项测试) +├── 📐 drawio/agent-harness/ # Draw.io CLI(138 项测试) +├── ✨ anygen/agent-harness/ # AnyGen CLI(50 项测试) +├── 🟩 nsight-graphics/agent-harness/ # Nsight Graphics CLI(51 项测试) +├── 🐞 lldb/agent-harness/ # LLDB CLI(27 项测试) +├── 📈 unrealinsights/agent-harness/ # Unreal Insights CLI(50 项测试) +├── 📦 sbox/agent-harness/ # s&box CLI(244 项测试: 157 单元 + 17 orchestrator + 50 e2e + 20 exit-code) +└── 🎨 sketch/agent-harness/ # Sketch CLI(19 项测试,Node.js) +``` + +每个 `agent-harness/` 包含一个可安装的 Python 包,位于 `cli_anything.<软件名>/` 下,包含 Click CLI、核心模块、工具类(含 `repl_skin.py` 和后端适配器)以及完整的测试。 + +--- + +## 🎯 插件命令 + +| 命令 | 说明 | +|-----|------| +| `/cli-anything <软件路径或仓库>` | 构建完整的 CLI —— 全部 7 个阶段 | +| `/cli-anything:refine <软件路径> [聚焦方向]` | 优化已有的 CLI —— 通过差距分析扩展覆盖面 | +| `/cli-anything:test <软件路径或仓库>` | 运行测试并更新 TEST.md | +| `/cli-anything:validate <软件路径或仓库>` | 按照 HARNESS.md 标准进行验证 | +| `/cli-anything:list` | 列出所有已安装和已生成的 CLI 工具 | + +### 使用示例 + +```bash +# 从本地源码为 GIMP 构建完整 CLI +/cli-anything /home/user/gimp + +# 从 GitHub 仓库构建 +/cli-anything https://github.com/blender/blender + +# 优化已有的 CLI —— 全面差距分析 +/cli-anything:refine /home/user/gimp + +# 带聚焦方向的优化 +/cli-anything:refine /home/user/shotcut "画中画和视频叠加合成" + +# 运行测试并更新 TEST.md +/cli-anything:test /home/user/inkscape + +# 按照 HARNESS.md 标准验证 +/cli-anything:validate /home/user/audacity +``` + +--- + +## 🎮 实际操作演示 + +以 `cli-anything-libreoffice` 为例,看看 Agent 能做什么: + +```bash +# 创建一个 Writer 文档 +$ cli-anything-libreoffice document new -o report.json --type writer +✓ Created Writer document: report.json + +# 添加内容 +$ cli-anything-libreoffice --project report.json writer add-heading -t "Q1 Report" --level 1 +✓ Added heading: "Q1 Report" + +$ cli-anything-libreoffice --project report.json writer add-table --rows 4 --cols 3 +✓ Added 4×3 table + +# 通过 LibreOffice headless 导出为真实 PDF +$ cli-anything-libreoffice --project report.json export render output.pdf -p pdf --overwrite +✓ Exported: output.pdf (42,831 bytes) via libreoffice-headless + +# JSON 模式供 Agent 消费 +$ cli-anything-libreoffice --json document info --project report.json +{ + "name": "Q1 Report", + "type": "writer", + "pages": 1, + "elements": 2, + "modified": true +} +``` + +### REPL 模式 + +``` +$ cli-anything-blender +╔══════════════════════════════════════════╗ +║ cli-anything-blender v1.0.0 ║ +║ Blender CLI for AI Agents ║ +╚══════════════════════════════════════════╝ + +blender> scene new --name ProductShot +✓ Created scene: ProductShot + +blender[ProductShot]> object add-mesh --type cube --location 0 0 1 +✓ Added mesh: Cube at (0, 0, 1) + +blender[ProductShot]*> render execute --output render.png --engine CYCLES +✓ Rendered: render.png (1920×1080, 2.3 MB) via blender --background + +blender[ProductShot]> exit +Goodbye! 👋 +``` + +--- + +## 📖 标准手册:HARNESS.md + +HARNESS.md 是我们通过自动化 CLI 生成,使任意软件具备 Agent 可用性的权威标准操作流程(SOP)。 + +它记录了在自动化生成过程中经验证并沉淀出的模式与方法论。 + +这本手册提炼了成功构建全部 13 套生产级 CLI 的关键经验。 + +### 核心经验 + +| 经验 | 说明 | +|-----|------| +| **必须用真实软件** | CLI 必须调用真实应用进行渲染。不能用 Pillow 替代 GIMP,不能自己写渲染器替代 Blender。正确做法:生成合法的项目文件 → 调用真实后端。 | +| **渲染鸿沟** | GUI 应用在渲染时才应用特效。如果你的 CLI 操作了项目文件但用了简陋的导出工具,特效会被静默丢弃。正确做法:原生渲染器 → 滤镜转译 → 渲染脚本。 | +| **滤镜转译** | 在不同格式间映射特效时(如 MLT → ffmpeg),要注意:重复滤镜合并、交错的流排序、参数空间差异、无法映射的特效。 | +| **时间码精度** | 非整数帧率(如 29.97fps)会导致累积舍入误差。用 `round()` 而非 `int()`,显示时用整数运算,测试中允许 ±1 帧容差。 | +| **输出验证** | 永远不要因为进程退出码为 0 就信任导出成功。要验证:魔术字节、ZIP/OOXML 结构、像素分析、音频 RMS 电平、时长检查。 | + +> 完整方法论见:[`cli-anything-plugin/HARNESS.md`](cli-anything-plugin/HARNESS.md) + +--- + +## 📦 安装与使用 + +### Claude Code 用户 + +```bash +# 添加市场并安装(推荐) +/plugin marketplace add HKUDS/CLI-Anything +/plugin install cli-anything + +# 为任何有代码库的软件生成 CLI +/cli-anything:cli-anything <软件路径或仓库> +``` + +### OpenCode 用户 + +```bash +# 克隆仓库 +git clone https://github.com/HKUDS/CLI-Anything.git + +# 请确保使用最新 OpenCode,旧版本可能使用不同的命令目录路径 +# 复制命令和 HARNESS.md 到 OpenCode 命令目录 +cp CLI-Anything/opencode-commands/*.md ~/.config/opencode/commands/ +cp CLI-Anything/cli-anything-plugin/HARNESS.md ~/.config/opencode/commands/ + +# 为任何有代码库的软件生成 CLI +/cli-anything <软件路径或仓库> +``` + +### 使用生成的 CLI + +```bash +# 安装任意生成的 CLI +cd <软件名>/agent-harness +pip install -e . + +# 验证安装 +which cli-anything-<软件名> + +# 开始使用 +cli-anything-<软件名> --help +cli-anything-<软件名> # 进入 REPL +cli-anything-<软件名> --json <命令> # JSON 输出供 Agent 使用 +``` + +### 运行测试 + +```bash +# 运行某个 CLI 的测试 +cd <软件名>/agent-harness +python3 -m pytest cli_anything/<软件名>/tests/ -v + +# 强制安装模式(推荐用于验证) +CLI_ANYTHING_FORCE_INSTALLED=1 python3 -m pytest cli_anything/<软件名>/tests/ -v -s +``` + +--- + +## 🤝 参与贡献 + +欢迎贡献!CLI-Anything 天然支持扩展: + +- **新的目标软件** — 用插件为任意有代码库的软件生成 CLI,然后通过 [`cli-anything-plugin/PUBLISHING.md`](cli-anything-plugin/PUBLISHING.md) 提交你的成果。 +- **方法论改进** — 向 `HARNESS.md` 提 PR,把新的经验教训沉淀下来 +- **插件增强** — 新命令、阶段优化、更好的验证逻辑 +- **测试覆盖** — 更多端到端场景、边界情况、工作流测试 + +### 已知局限 + +- **依赖强大的基础模型** — CLI-Anything 依赖前沿级别的模型(如 Claude Opus 4.6、Claude Sonnet 4.6、GPT-5.4)才能可靠地生成 harness。较弱或较小的模型可能产出不完整或有误的 CLI,需要大量人工修正。 +- **依赖可用的源代码** — `/cli-anything` 基于源码进行分析和生成。如果目标软件只提供编译后的二进制文件,需要反编译才能获取代码,harness 的质量和覆盖率会显著下降。 +- **可能需要迭代优化** — 单次 `/cli-anything` 运行不一定能完整覆盖所有功能。通常需要执行一次或多次 `/refine` 命令,才能将 CLI 的性能和覆盖率推到生产级水平。 + +--- + +## 📖 文档 + +| 文档 | 说明 | +|-----|------| +| [`cli-anything-plugin/HARNESS.md`](cli-anything-plugin/HARNESS.md) | 方法论 SOP — 唯一权威来源 | +| [`cli-anything-plugin/README.md`](cli-anything-plugin/README.md) | 插件文档 — 命令、选项、阶段 | +| [`cli-anything-plugin/QUICKSTART.md`](cli-anything-plugin/QUICKSTART.md) | 5 分钟快速上手 | +| [`cli-anything-plugin/PUBLISHING.md`](cli-anything-plugin/PUBLISHING.md) | 分发与发布指南 | + +每个生成的 CLI 还包含: + +- `<软件名>.md` — 该应用的架构 SOP +- `tests/TEST.md` — 测试计划和结果文档 + +--- + +## ⭐ Star History + +如果 CLI-Anything 帮到了你,给个 Star 吧!⭐ + + + +--- + +## 📚 引用 + +如果 CLI-Anything 对你有帮助,请引用我们的技术报告: + +```bibtex +@misc{yang2026clianythingagentnativecomputeruse, + title={CLI-Anything: Towards Agent-Native Computer Use}, + author={Yuhao Yang and Tianyu Fan and Chao Huang}, + year={2026}, + eprint={2606.03854}, + archivePrefix={arXiv}, + primaryClass={cs.HC}, + url={https://arxiv.org/abs/2606.03854}, +} +``` + +--- + +## 📄 License + +Apache License 2.0 — 可自由使用、修改和分发。 + +--- + +
+ +**CLI-Anything** — *一行命令,让任何软件成为 Agent 的原生工具。* + +为 AI Agent 时代而生 | 13 款专业软件实测 | 1,774 项测试全部通过 + +
+ +CLI-Anything Icon + +
+ +

+ 感谢访问 ✨ CLI-Anything!

+ Views +

diff --git a/README_DE.md b/README_DE.md new file mode 100644 index 0000000..04111ca --- /dev/null +++ b/README_DE.md @@ -0,0 +1,1138 @@ +

  CLI-Anything: Jede Software Agent-Native machen

+ +
+HKUDS%2FCLI-Anything | Trendshift +
+ +

+ Die Software von heute ist für Menschen👨‍💻. Die Nutzer von morgen sind Agenten🤖.
+CLI-Anything: Die Brücke zwischen KI-Agenten und der Software der Welt

+

+ +**🌐 [CLI-Hub](https://hkuds.github.io/CLI-Anything/)**: Alle von der Community gebauten CLIs auf **[CLI-Hub](https://hkuds.github.io/CLI-Anything/)** entdecken — mit einem Befehl installieren. Du willst dein eigenes CLI beitragen? [PR einreichen](https://github.com/HKUDS/CLI-Anything/blob/main/CONTRIBUTING.md) — der Hub aktualisiert sich sofort. + +

+ Schnellstart + Demos + Tests + Lizenz +

+ +

+ Python + Click + Pytest + Coverage + Output + Feishu +WeChat +

+ +**Ein Befehl**: Mache jede Software agent-bereit für Pi, OpenClaw, nanobot, Cursor, Claude Code usw.  [**English**](README.md) | [**中文文档**](README_CN.md) | [**日本語ドキュメント**](README_JA.md) + +

+ CLI-Anything Tipp-Demo +

+ +

+ CLI-Anything Teaser +

+ +--- + +## 📰 Neuigkeiten + +> Danke an die Community für die unschätzbaren Beiträge! Täglich kommen weitere Updates dazu. + +- **2026-05-20** 🎛️ **Rekordbox CLI** gemerged (#252) mit abgesicherten SQLCipher-Schreibpfaden, Backup-Pflicht bei erzwungenen Schreibvorgängen, Smoke-Coverage und Root-Skill-Sync. 📚 **Calibre CLI** gemerged (#223) mit Library-/Suche-/Metadaten-/Konvertierungs-/Export-Workflows, 41 Unit-Tests, echtem Calibre-E2E-Nachweis und Root-Skill-Validierung. 🧊 **3MF CLI** gemerged (#209) mit Mesh-Inspektion, Hole-Resizing, Reparatur, Vergleich und erhaltenen Triangle-Attributen. 🎙️ **MiniMax CLI** gemerged (#189) mit Chat-/TTS-Workflows, JSON-sicherem Modell-/Voice-Listing, REPL-Quote-Handling und Smoke-/E2E-Coverage. 🎮 **UEAtelier** in der Registry aufgenommen (#297) als Unreal-Editor-MCP-Self-Extension-Workbench mit Python-CLI-Proxy. + +- **2026-05-19** 🛠️ Bestehende Harnesses haben einen Quality-/Security-Pass bekommen — **Zoom** lädt Recordings über direkte URLs herunter (#294), **Obsidian**-Suche nutzt jetzt die Vendor-Content-Types der Local REST API (#289), **LibreOffice** Headless-Konvertierung läuft robuster auf macOS (#290), und XML-/SVG-/ODF-/MLT-/MusicXML-/CSL-Parsing routet untrusted Input nun durch `defusedxml` (#296). + +- **2026-05-18** 📈 README-Präsentation aufgefrischt mit dem Trendshift-Badge und zentriertem Projekt-Header-Feinschliff (#285, #286); der Landing-Bereich bleibt auf Discovery und Projektdynamik fokussiert. + +- **2026-05-17** 🌐 **CLI-Hub**-Registry-Handling wurde gehärtet (#281) — Registry-Einträge werden jetzt vor dem `_source`-Tagging kopiert, damit gecachte oder gemockte Registry-Daten nicht mehr in-place mutiert werden. + +- **2026-05-16** 🔧 **n8n** hat den REPL-Banner-Crash-Fix bekommen, der später nach main gemerged wurde (#280), womit der Start-Pfad ohne Subcommand interaktiv wieder funktioniert — inklusive Regression-Coverage. + +
+Frühere Neuigkeiten (10.–18. April) + +- **2026-04-18** 🧩 **Alle SKILL.md-Dateien werden jetzt unter dem Top-Level-Verzeichnis `skills/` vereinheitlicht** — jeder CLI-Skill lässt sich aus einer einzigen kanonischen Quelle installieren via `npx skills add HKUDS/CLI-Anything --skill -g -y`. Außerdem haben wir Root-Skill-Validierungs-CI ergänzt, Contribution-/PR-Docs sowie REPL-Skill-Path-Hinweise auf das neue Layout angepasst und das **CLI-Hub**-Install-First-Frontend rund um den neuen `npx skills`-Flow überarbeitet. + +- **2026-04-17** 🌐 **CLI-Hub** hat einen weiteren Install-UX-Pass bekommen — öffentliche Registry-Metadaten und Skill-Coverage wurden geschärft, das Visit-Counting korrigiert und das Web-Hub weiter verfeinert. 🧪 **Shotcut**-Render-Output-Dauer wurde gefixt (#92). 📝 **SKILL**-Contribution-Pfade wurden für den neuen Docs-Flow korrigiert (#224), und der Skill-Generator behandelt leere Intros jetzt sicher (#203). + +- **2026-04-16** 🗺️ **QGIS CLI** gemerged (#207) — ein vollständiges GIS-/Map-Authoring-Harness ist gelandet. 🧬 **UniMol Tools CLI** gemerged (#219) für Molekül-Modelling-Workflows. 🌐 **CLI-Hub** hat zudem weitere öffentliche CLIs aufgenommen, darunter **py4csr**, seinen generierten Meta-Skill aufgefrischt, SKILL-Contribution-Docs korrigiert und die `apt-get`-Paket-Extraktion in der Skill-Generierung gefixt (#204). + +- **2026-04-16** 📈 **Unreal Insights CLI** ausgebaut — Background-Capture-Session-Steuerung ergänzt (`capture start/status/snapshot/stop`), Engine-Root-passende `UnrealInsights.exe`-Resolution-/Build-Flows hinzugefügt und Docs/Tests für den neuen Orchestrierungs-Workflow überarbeitet. + +- **2026-04-15** 🌐 **CLI-Hub** auf **v0.2.0** aktualisiert — das PyPI-Paket unterstützt jetzt öffentliche CLIs aus mehreren Install-Quellen (`pip`, `npm`, `brew`, gebündelte/Systemtools), gestützt auf eine neue `public_registry.json`. Das Hub-Frontend wurde mit getrennten Decks für **CLI-Anything CLIs** und **Public CLIs** neu gestaltet, und Live-End-to-End-Checks decken jetzt echte Install-, Update- und Uninstall-Flows über pip- und npm-Pakete hinweg ab. + +- **2026-04-14** 🧭 **Safari CLI** gemerged (#212) und in die Hub-Registry aufgenommen — Browser-Automation via `safari-mcp`. 🎬 **Kdenlive** hat zudem Kompatibilitäts-Fixes für Gen-5-Project-Output und ungültige Projekt-Generierung bekommen. + +- **2026-04-13** 📓 **Obsidian CLI** gemerged (#211) — Knowledge-Management-Harness via Local REST API mit 48 Unit-Tests und 7 E2E-Tests. ⛓️ **Eth2-Quickstart CLI** gemerged (#195) — Ethereum-Staking-Node-Management-Harness. 📚 **Zotero CLI** auf v0.4.1 aktualisiert (#201) — wird jetzt aus seinem Standalone-Repo geliefert, und CLI-Hub unterstützt jetzt remote `skill_md`-URLs. + +- **2026-04-11** 🔗 **n8n CLI** gemerged (#188) — Workflow-Automation-Harness für selbst gehostete Automation-Flows. 🔧 **Exa CLI**-Fix (#205) ergänzt den `x-exa-integration`-Header für Usage-Tracking. 📦 **CLI-Hub** hat zudem seinen PyPI-Auto-Publish-Workflow und eine Package-Refresh-Pipeline bekommen. + +- **2026-04-10** 📦 **CLI-Hub Package Manager** gelauncht — `pip install cli-anything-hub`, um CLI-Anything-Harnesses aus einem Befehl heraus zu browsen, zu suchen, zu installieren, zu aktualisieren und zu deinstallieren. Das Web-Hub hat zudem seinen ersten Install-fokussierten Frontend-Refresh und die „Empower yourself"-Toolkit-Karte bekommen. + +
+ +
+Frühere Neuigkeiten (1.–9. April) + +- **2026-04-09** 🧹 Cleanup- und Docs-Pass (#200) — Openscreen-Test-Zwischensummen gefixt, Openscreen ins chinesische README und die Projektstruktur aufgenommen und die `/cli-anything`-Befehlssyntax in den Docs klargestellt. + +- **2026-04-08** 🎬 **Openscreen CLI** gemerged (#183) — Screen-Recording-Editor-Harness mit 101 Tests. ☁️ **CloudAnalyzer CLI** gemerged (#181) — Cloud-Cost-Analysis-Harness mit 27 Befehlen. 🌊 **SeaClip / PM2 / ChromaDB**-Harnesses gemerged (#129). + +- **2026-04-07** 🔄 **Dify Workflow CLI** gemerged (#191) — Workflow-Automation-Wrapper. 🔧 **Inkscape** Auto-Save-Fix (#193, behebt #182). 🛡️ **DomShell-Security-Hardening** (#156) — URL-Validierung und DOM-Sanitization für das Browser-CLI. 🥧 **Pi Coding Agent**-Extension gemerged (#178). + +- **2026-04-06** 🔍 **Exa CLI** gemerged (#172) — KI-gestütztes Web-Search-und-Answers-Harness. 🎮 **Godot CLI** gemerged (#140) — Game-Engine-Harness mit voller Demo-Game-E2E-Pipeline. ☁️ **CloudAnalyzer** Review-Fixes und Frontend-Verbesserungen sind ebenfalls gelandet. + +- **2026-04-03** 🧪 **WireMock CLI** gemerged (#170) — HTTP-Mock-Server-Harness für API-Tests. 🥧 **Pi Coding Agent**-Extension-Support ist ebenfalls gelandet, und CLI-Demo-Aufzeichnungen wurden den Docs hinzugefügt. + +- **2026-04-01** ⚔️ **Slay the Spire II CLI** gemerged (#148) — Deck-Building-Roguelike-Harness. 🎥 **VideoCaptioner CLI** gemerged (#166) — KI-gestütztes Video-Captioning-Harness. 🛰️ **IntelWatch** wurde der Registry für B2B-OSINT-Workflows hinzugefügt. + +
+ +
+Frühere Neuigkeiten (23.–30. März) + +- **2026-03-30** 🏗️ **CLI-Anything v0.2.0** — HARNESS.md Progressive-Disclosure-Redesign. Detaillierte Guides nach `guides/` ausgelagert für On-Demand-Loading. Phasen 1–7 sind jetzt durchgehend. Key Principles und Rules in einem einzigen autoritativen Abschnitt zusammengeführt. + +- **2026-03-29** 📐 Blender-Skill-Docs aktualisiert — absolute Render-Pfade werden erzwungen und Voraussetzungen korrigiert. + +- **2026-03-28** 🌐 **CLIBrowser** in die CLI-Hub-Registry aufgenommen für agent-zugängliche Browser-Automation. + +- **2026-03-27** 📚 Zotero-SKILL.md mit Agent-orientierten Constraints erweitert; REPL-Config- und Executable-Resolution-Fixes. + +- **2026-03-26** 📖 **Zotero CLI**-Harness für Zotero Desktop gelandet (Library-Management, Sammlungen, Zitationen). Draw.io Custom-ID-Bugfix (#132) und registry.json-Syntax-Fix. + +- **2026-03-25** 🎮 **RenderDoc CLI** gemerged für GPU-Frame-Capture-Analyse. FreeCAD für v1.1 aktualisiert. Blender EEVEE-Engine-Name korrigiert. Zoom-Token-Permissions gehärtet. + +- **2026-03-24** 🏭 **FreeCAD CLI** ergänzt mit 258 Befehlen über 17 Gruppen. **iTerm2**- und **Teltonika RMS**-Harnesses in die Registry aufgenommen. + +- **2026-03-23** 🤖 **CLI-Hub Meta-Skill** gelauncht — Agenten können CLIs jetzt autonom entdecken und installieren. **Krita CLI**-Harness gemerged für Digital Painting. + +
+ +
+Frühere Neuigkeiten (11.–22. März) + +- **2026-03-22** 🎵 **MuseScore CLI** gemerged mit Transponieren, Export und Instrument-Management. + +- **2026-03-21** 🔧 Infrastruktur-Verbesserungen — Test-Harnesses und Dokumentation über mehrere CLIs hinweg überarbeitet. Erweiterte Windows-Kompatibilität für mehrere Backends. + +- **2026-03-20** 🌐 **Novita AI** CLI ergänzt für OpenAI-kompatiblen API-Zugang. Registry-Metadaten-Verbesserungen für bessere Hub-Discovery. + +- **2026-03-19** 📦 Verfeinerungen der Paketstruktur über die Harnesses hinweg. Verbesserte SKILL.md-Generierung mit besserer Befehlsdokumentation. + +- **2026-03-18** 🧪 Erweiterung der Test-Coverage — zusätzliche E2E-Szenarien und Edge-Case-Validierung über mehrere CLIs hinweg. + +- **2026-03-17** 🌐 Den **[CLI-Hub](https://hkuds.github.io/CLI-Anything/)** gelauncht — eine zentrale Registry, in der jedes CLI mit einem einzigen `pip`-Befehl gebrowst, gesucht und installiert werden kann. + +- **2026-03-16** 🤖 **SKILL.md-Generierung** ergänzt (Phase 6.5) — jedes generierte CLI bringt jetzt eine KI-entdeckbare Skill-Definition mit. + +- **2026-03-15** 🐾 Support für **OpenClaw** aus der Community! Windows-`cygpath`-Guard für plattformübergreifenden Support gemerged. + +- **2026-03-14** 🔒 Eine GIMP Script-Fu-Path-Injection-Vulnerability gefixt und die **japanische README**-Übersetzung ergänzt. + +- **2026-03-13** 🔌 **Qodercli**-Plugin offiziell als Community-Beitrag gemerged inkl. dedizierter Setup-Skripte. + +- **2026-03-12** 📦 **Codex-Skill**-Integration gelandet — CLI-Anything erreicht damit eine weitere KI-Coding-Plattform. + +- **2026-03-11** 📞 **Zoom**-Videokonferenz-Harness als 11. unterstützte Anwendung hinzugefügt. + +
+ +--- + +## 🤔 Warum CLI? + +Die Kommandozeile ist die universelle Schnittstelle — gleichermaßen für Menschen und KI-Agenten: + +• **Strukturiert & komponierbar** — Textbefehle passen perfekt zum Ausgabeformat von LLMs und lassen sich zu komplexen Workflows verketten + +• **Leichtgewichtig & universell** — minimaler Overhead, läuft auf jedem System ohne Abhängigkeiten + +• **Selbsterklärend** — der `--help`-Flag liefert automatische, vom Agenten entdeckbare Dokumentation + +• **Bewährt im Einsatz** — Claude Code führt täglich Tausende reale Workflows über CLIs aus + +• **Agent-First-Design** — strukturierte JSON-Ausgaben ersparen jedes Parser-Theater + +• **Deterministisch & zuverlässig** — konsistente Ergebnisse machen das Verhalten des Agenten vorhersehbar + +## 🚀 Schnellstart + +### Voraussetzungen + +- **Python 3.10+** +- Die Zielsoftware muss installiert sein (z. B. GIMP, Blender, LibreOffice oder deine eigene Anwendung) +- Unterstützte KI-Coding-Agenten: [Claude Code](#-claude-code) | [OpenClaw](#-openclaw) | [OpenCode](#-opencode) | [Codex](#-codex) | [Qodercli](#-qodercli) | [GitHub Copilot CLI](#-github-copilot-cli) | [Weitere Plattformen](#-weitere-plattformen-bald) + +### Wähle deine Agent-Plattform + +
+

⚡ Claude Code

+ +**Schritt 1: Marketplace hinzufügen** + +CLI-Anything wird als Claude-Code-Plugin-Marketplace auf GitHub bereitgestellt. + +```bash +# CLI-Anything-Marketplace hinzufügen +/plugin marketplace add HKUDS/CLI-Anything +``` + +**Schritt 2: Plugin installieren** + +```bash +# cli-anything-Plugin aus dem Marketplace installieren +/plugin install cli-anything +``` + +Fertig. Das Plugin steht in deinen Claude-Code-Sessions zur Verfügung. + +**Schritt 3: Mit einem Befehl ein CLI bauen** + +```bash +# /cli-anything:cli-anything +# Komplettes CLI für GIMP generieren (alle 7 Phasen) +/cli-anything:cli-anything ./gimp + +# Hinweis: bei Claude Code < 2.x stattdessen "/cli-anything" verwenden. +``` + +Das fährt die komplette Pipeline durch: +1. 🔍 **Analyse** — Source-Code scannen, GUI-Aktionen auf APIs mappen +2. 📐 **Design** — Befehlsgruppen, State-Modell und Ausgabeformate entwerfen +3. 🔨 **Implementierung** — Click-CLI mit REPL, JSON-Output, Undo/Redo bauen +4. 📋 **Testplan** — TEST.md mit Unit- und E2E-Testplan erstellen +5. 🧪 **Tests schreiben** — vollständige Test-Suite implementieren +6. 📝 **Dokumentation** — TEST.md mit Ergebnissen aktualisieren +7. 📦 **Veröffentlichung** — `setup.py` anlegen, ins PATH installieren + +**Schritt 4 (optional): CLI verfeinern und erweitern** + +Nach dem ersten Build lässt sich das CLI iterativ verfeinern — Abdeckung erweitern, fehlende Funktionen ergänzen: + +```bash +# Breite Verfeinerung — der Agent analysiert Lücken über alle Funktionen +/cli-anything:refine ./gimp + +# Fokussierte Verfeinerung — gezielt auf einen Funktionsbereich +/cli-anything:refine ./gimp "Ich möchte CLI-Unterstützung für Batch-Bildverarbeitung und Filter" +``` + +Der `refine`-Befehl macht eine Gap-Analyse zwischen vollständiger Software-Funktionalität und aktueller CLI-Abdeckung und implementiert neue Befehle, Tests und Doku für die gefundenen Lücken. Mehrfach ausführbar, um die Abdeckung schrittweise zu erweitern — jeder Lauf ist inkrementell und nicht-destruktiv. + +
+Alternative: manuelle Installation + +Wenn du den Marketplace nicht nutzen willst: + +```bash +# Repo klonen +git clone https://github.com/HKUDS/CLI-Anything.git + +# Plugin in das Plugin-Verzeichnis von Claude Code kopieren +cp -r CLI-Anything/cli-anything-plugin ~/.claude/plugins/cli-anything + +# Plugins neu laden +/reload-plugins +``` + +
+ +
+ +
+

⚡ OpenCode (experimentell)

+ +**Schritt 1: Commands installieren** + +Kopiere die CLI-Anything-Commands **und** die `HARNESS.md` in das Command-Verzeichnis von OpenCode: + +```bash +# Repo klonen +git clone https://github.com/HKUDS/CLI-Anything.git + +# Globale Installation (in allen Projekten verfügbar) +cp CLI-Anything/opencode-commands/*.md ~/.config/opencode/commands/ +cp CLI-Anything/cli-anything-plugin/HARNESS.md ~/.config/opencode/commands/ + +# Oder projektweite Installation +cp CLI-Anything/opencode-commands/*.md .opencode/commands/ +cp CLI-Anything/cli-anything-plugin/HARNESS.md .opencode/commands/ +``` + +> **Hinweis:** Die `HARNESS.md` ist die Methodik-Spezifikation, auf die alle Commands verweisen. Sie muss im selben Verzeichnis wie die Commands liegen. + +Damit kommen fünf Slash-Commands hinzu: `/cli-anything`, `/cli-anything-refine`, `/cli-anything-test`, `/cli-anything-validate`, `/cli-anything-list`. + +**Schritt 2: Mit einem Befehl ein CLI bauen** + +```bash +# Komplettes CLI für GIMP generieren (alle 7 Phasen) +/cli-anything ./gimp + +# Aus einem GitHub-Repo bauen +/cli-anything https://github.com/blender/blender +``` + +Die Commands laufen als Sub-Tasks und folgen derselben 7-Phasen-Methodik wie in Claude Code. + +**Schritt 3 (optional): CLI verfeinern und erweitern** + +```bash +# Breite Verfeinerung — der Agent analysiert Lücken über alle Funktionen +/cli-anything-refine ./gimp + +# Fokussierte Verfeinerung — gezielt auf einen Funktionsbereich +/cli-anything-refine ./gimp "Batch-Verarbeitung und Filter" +``` + +
+ +
+ +

⚡ Qodercli Community

+ +**Schritt 1: Plugin registrieren** + +```bash +git clone https://github.com/HKUDS/CLI-Anything.git +bash CLI-Anything/qoder-plugin/setup-qodercli.sh +``` + +Damit wird das cli-anything-Plugin in `~/.qoder.json` eingetragen. Starte danach eine neue Qodercli-Session. + +**Schritt 2: CLI-Anything aus Qodercli nutzen** + +```bash +/cli-anything:cli-anything ./gimp +/cli-anything:refine ./gimp "Batch-Verarbeitung und Filter" +/cli-anything:validate ./gimp +``` +
+ +
+ +

⚡ OpenClaw

+ +**Schritt 1: Skill installieren** + +CLI-Anything stellt eine native OpenClaw-`SKILL.md` bereit. Kopiere sie in das Skill-Verzeichnis von OpenClaw: + +```bash +# Repo klonen +git clone https://github.com/HKUDS/CLI-Anything.git + +# In den globalen Skill-Ordner installieren +mkdir -p ~/.openclaw/skills/cli-anything +cp CLI-Anything/openclaw-skill/SKILL.md ~/.openclaw/skills/cli-anything/SKILL.md +``` + +**Schritt 2: CLI bauen** + +Nach der Installation lässt sich der Skill in OpenClaw so aufrufen: + +`@cli-anything build a CLI for ./gimp` + +Der Skill folgt derselben 7-Phasen-Methodik wie Claude Code und OpenCode. + +
+ +
+ +

⚡ Codex experimentell Community

+ +**Schritt 1: Skill installieren** + +Mitgelieferten Installer ausführen: + +```bash +# Repo klonen +git clone https://github.com/HKUDS/CLI-Anything.git + +# Skill installieren +bash CLI-Anything/codex-skill/scripts/install.sh +``` + +Unter Windows PowerShell: + +```powershell +.\CLI-Anything\codex-skill\scripts\install.ps1 +``` + +Der Skill landet unter `$CODEX_HOME/skills/cli-anything` (bzw. `~/.codex/skills/cli-anything`, falls `CODEX_HOME` nicht gesetzt ist). + +Starte Codex danach neu, damit der Skill erkannt wird. + +**Schritt 2: CLI-Anything aus Codex nutzen** + +Beschreibe deine Aufgabe in natürlicher Sprache. Zum Beispiel: + +```text +Bau mir mit CLI-Anything ein Harness für ./gimp +Verfeinere mit CLI-Anything den Picture-in-Picture-Workflow für ./shotcut +Validiere ./libreoffice mit CLI-Anything +``` + +Der Codex-Skill wendet dieselbe Methodik an wie das Claude-Code-Plugin und die OpenCode-Commands; das Format der generierten Python-Harnesses bleibt unverändert. +
+ +
+ +

⚡ GitHub Copilot CLI Community

+ +**Schritt 1: Plugin installieren** + +```bash +git clone https://github.com/HKUDS/CLI-Anything.git +cd CLI-Anything +copilot plugin install ./cli-anything-plugin +``` + +Damit ist das CLI-Anything-Plugin im GitHub Copilot CLI installiert und in deinen Copilot-CLI-Sessions sofort verfügbar. + +**Schritt 2: CLI-Anything aus dem GitHub Copilot CLI nutzen** + +```bash +/cli-anything:cli-anything ./gimp +/cli-anything:refine ./gimp "Batch-Verarbeitung und Filter" +/cli-anything:validate ./gimp +``` + +
+ +
+

🔮 Weitere Plattformen (bald)

+ +CLI-Anything ist plattformunabhängig konzipiert. Unterstützung für weitere KI-Coding-Agenten ist geplant: + +- **Codex** — verfügbar über den mitgelieferten Skill in `codex-skill/` +- **Cursor** — bald +- **Windsurf** — bald +- **Dein Lieblingstool** — Beiträge willkommen! Eine Referenzimplementierung findest du im Verzeichnis `opencode-commands/`. + +
+ +### Das generierte CLI nutzen + +Egal auf welcher Plattform du es baust — die generierten CLIs funktionieren überall gleich: + +```bash +# Ins PATH installieren +cd gimp/agent-harness && pip install -e . + +# Von überall aufrufbar +cli-anything-gimp --help +cli-anything-gimp project new --width 1920 --height 1080 -o poster.json +cli-anything-gimp --json layer add -n "Background" --type solid --color "#1a1a2e" + +# In die interaktive REPL einsteigen +cli-anything-gimp +``` + +--- + +## 🤖 Stärke deine Agenten mit CLI-Hub + +CLI-Hub lässt Agenten autonom die CLIs entdecken, installieren und nutzen, die sie für eine Aufgabe brauchen. + +```bash +npx skills add HKUDS/CLI-Anything --skill cli-hub-meta-skill -g -y +``` + +**Ebenfalls verfügbar auf:** [ClawHub](https://clawhub.ai/yuh-yang/cli-anything-hub), [SkillHub](https://www.skillhub.club/web/skills/itsyuhao-cli-anything-hub), [SkillHub.cn](https://skillhub.cn/skills/cli-hub-meta-skill) + +Dann prompten: + +```text +Finde passende CLI-Software im CLI-Hub und erledige die Aufgabe: ... +``` + +Der Meta-Skill verweist Agenten auf den Live-Katalog von CLI-Hub, wo sie ein CLI auswählen, es installieren und seine eigene `SKILL.md` für die aufgabenspezifische Nutzung lesen können. + +--- + +## 💡 Die Vision von CLI-Anything: Agent-Native Software bauen + +• 🌐 **Universeller Zugang** — jede Software wird über strukturierte CLIs sofort agent-steuerbar. + +• 🔗 **Nahtlose Integration** — Agenten steuern jede Anwendung ohne API-Akrobatik, GUI-Hacks, Umbauten oder fragile Wrapper. + +• 🚀 **Zukunftsorientiertes Ökosystem** — ein Befehl verwandelt für Menschen gemachte Software in agent-native Werkzeuge. + +--- + +## 🔧 Wann CLI-Anything nutzen? + +| Kategorie | So wird es Agent-Native | Repräsentative Beispiele | +|----------|----------------------|----------| +| **📂 GitHub-Repositories** | Jedes Open-Source-Projekt mit automatischer CLI-Generierung in ein agent-steuerbares Tool verwandeln | VSCodium, WordPress, Calibre, Zotero, Joplin, Logseq, Penpot, Super Productivity | +| **🤖 AI/ML-Plattformen** | Mit strukturierten Befehlen Modell-Training, Inferenz-Pipelines und Hyperparameter-Tuning automatisieren | Stable Diffusion WebUI, ComfyUI, InvokeAI, Text-generation-webui, Open WebUI, Fooocus, Kohya_ss, AnythingLLM, SillyTavern | +| **📊 Daten & Analytics** | Programmatische Datenverarbeitung, Visualisierung und statistische Analyse-Workflows | JupyterLab, Apache Superset, Metabase, Redash, DBeaver, KNIME, Orange, OpenSearch Dashboards, Lightdash | +| **💻 Entwickler-Tools** | Code-Editor, Build, Test und Deployment-Prozesse über Command-Interfaces streamlinen | Jenkins, Gitea, Hoppscotch, Portainer, pgAdmin, SonarQube, ArgoCD, OpenLens, Insomnia, Beekeeper Studio | +| **🎨 Kreativ & Medien** | Content-Erstellung, Editing und Rendering-Workflows programmatisch steuern | Blender, GIMP, OBS Studio, Audacity, Krita, Kdenlive, Shotcut, Inkscape, Darktable, LMMS, Ardour | +| **🔬 Wissenschaftliches Rechnen** | Forschungs-Workflows, Simulationen und aufwendige Berechnungen automatisieren | ImageJ, FreeCAD, QGIS, ParaView, Gephi, LibreCAD, Stellarium, KiCad, JASP, Jamovi | +| **🏢 Enterprise & Office** | Business-Anwendungen und Produktivitäts-Tools in agent-zugängliche Systeme verwandeln | NextCloud, GitLab, Grafana, Mattermost, LibreOffice, AppFlowy, NocoDB, Odoo (Community), Plane, ERPNext | +| **📞 Kommunikation & Zusammenarbeit** | Mit strukturierten CLIs Meeting-Scheduling, Teilnehmer-Verwaltung, Aufzeichnungen und Reports automatisieren | Zoom, Jitsi Meet, BigBlueButton, Mattermost | +| **📐 Diagramme & Visualisierung** | Diagramme, Flowcharts, Architekturskizzen und visuelle Dokumente programmatisch erzeugen und bearbeiten | Draw.io (diagrams.net), Mermaid, PlantUML, Excalidraw, yEd | +| **✨ AI-Content-Generation** | Über AI-gestützte Cloud-APIs professionelle Artefakte erzeugen (Slides, Dokumente, Diagramme, Websites, Research Reports) | [AnyGen](https://www.anygen.io), Gamma, Beautiful.ai, Tome | + +--- + +## Hauptmerkmale von CLI-Anything + +### Die Lücke zwischen Agent und Software +KI-Agenten können hervorragend schlussfolgern, scheitern aber regelmäßig daran, professionelle Software wirklich zu bedienen. Aktuelle Ansätze sind entweder brüchige UI-Automation, eingeschränkte APIs oder vereinfachte Re-Implementierungen, die 90 % der Funktionen verlieren. + +**Die Lösung von CLI-Anything**: Jede professionelle Software in ein agent-natives Werkzeug verwandeln — ohne Funktion einzubüßen. + +| **Aktuelle Herausforderung** | **Lösung von CLI-Anything** | +|----------|----------------------| +| 🤖 „KI kann keine echten Tools bedienen" | Direkte Integration mit echten Software-Backends (Blender, LibreOffice, FFmpeg) — voller Funktionsumfang, null Kompromisse | +| 💸 „UI-Automation bricht ständig" | Keine Screenshots, keine Klicks, keine RPA-Fragilität. Reine, strukturierte Kommandozeilen-Zuverlässigkeit | +| 📊 „Agenten brauchen strukturierte Daten" | Eingebautes JSON-Output für nahtlose Agent-Nutzung + menschenlesbares Format zum Debuggen | +| 🔧 „Custom-Integrationen sind teuer" | Ein Claude-Plugin generiert über eine bewährte 7-Phasen-Pipeline automatisch ein CLI für beliebige Codebases | +| ⚡ „Lücke zwischen Prototyp und Produktion" | Über 1.508 Tests und Validierung gegen echte Software. Erprobt an 11 professionellen Anwendungen | + +--- + +## 🎯 Was kannst du mit CLI-Anything machen? + + + + + + + +
+ +### 🛠️ Lass Agenten deine Workflows übernehmen + +Egal ob professionell oder Alltag — gib einfach eine Codebase an `/cli-anything`. Für Kreatives: GIMP, Blender, Shotcut. Für den Alltag: LibreOffice, OBS Studio. Keine Quellen? Such eine Open-Source-Alternative und übergib die. Heraus kommt ein vollständiges, agent-taugliches CLI — sofort. + + + +### 🔗 Verstreute APIs zu einem CLI bündeln + +Genervt davon, fragmentierte Web-Service-APIs zu jonglieren? Gib die Doku oder das SDK-Manuskript an `/cli-anything`, und der Agent baut dir ein **leistungsstarkes, zustandsbehaftetes CLI**, das einzelne Endpoints zu kohärenten Befehlsgruppen verschmilzt. Statt Dutzenden API-Aufrufen ein Tool — token-sparender und schlagkräftiger zugleich. + + + +### 🚀 GUI-Agenten ersetzen oder verstärken + +CLI-Anything **ersetzt GUI-basierte Agenten komplett** — keine Screenshots, kein brüchiges Pixel-Klicken nötig. Noch spannender: Wenn du eine Software per `/cli-anything` durchläufst, kannst du allein mit Code und Terminal **Agent-Tasks, Evaluatoren und Benchmarks synthetisieren** — vollautomatisch, iterativ verbesserbar, deutlich effizienter. + +
+ +--- + +## ✨ ⚙️ So funktioniert CLI-Anything + + + + + + + + + + + + + + +
+ +### 🏗️ Vollautomatische 7-Phasen-Pipeline +Von der Codebase-Analyse bis zur PyPI-Veröffentlichung — das Plugin erledigt Architektur-Design, Implementierung, Testplanung, Test-Schreiben und Dokumentation komplett automatisch. + + + +### 🎯 Echte Software-Integration +Direkte Aufrufe in die tatsächliche Anwendung für echtes Rendering. LibreOffice erzeugt PDFs, Blender rendert 3D-Szenen, Audacity verarbeitet Audio über sox. **Null Kompromisse**, **keine Spielzeug-Implementierungen**. + +
+ +### 🔁 Smartes Session-Management +Persistenter Projekt-State mit Undo/Redo und eine vereinheitlichte REPL-Oberfläche (ReplSkin) für ein konsistentes interaktives Erlebnis über alle CLIs hinweg. + + + +### 📦 Installation ohne Konfiguration +Ein simples `pip install -e .` fügt `cli-anything-` direkt zum PATH hinzu. Agenten finden die Tools über das Standard-`which`-Kommando. Kein Setup, kein Wrapper. + +
+ +### 🧪 Tests auf Produktionsniveau +Mehrschichtige Validierung: Unit-Tests mit synthetischen Daten, End-to-End-Tests gegen echte Dateien und Software, plus CLI-Subprozess-Verifikation der installierten Befehle. + + + +### 🐍 Saubere Paket-Architektur +Alle CLIs leben im `cli_anything.*`-Namespace — kein Konflikt, pip-installierbar, einheitliches Naming: `cli-anything-gimp`, `cli-anything-blender` usw. + +
+ +### 🤖 SKILL.md-Generierung + +Jedes generierte CLI hat jetzt eine kanonische `SKILL.md` unter `skills/cli-anything-/SKILL.md`. Damit wird das aktuelle Monorepo direkt von `npx skills` konsumierbar, während eine paketierte Kompatibilitätskopie unter `cli_anything//skills/SKILL.md` das Verhalten installierter Harnesses bewahrt. + +**Was SKILL.md bereitstellt:** +- **YAML-Frontmatter** mit Name und Beschreibung für die Agent-Skill-Discovery +- **Befehlsgruppen** mit allen verfügbaren Subcommands dokumentiert +- **Nutzungsbeispiele** für gängige Workflows +- **Agent-spezifische Hinweise** für JSON-Output, Fehlerbehandlung und programmatische Nutzung + +SKILL.md-Dateien werden während Phase 6.5 der Pipeline automatisch über `skill_generator.py` erzeugt, der die Metadaten direkt aus den Click-Decoratoren des CLIs, aus `setup.py` und aus dem README extrahiert. Der Generator schreibt jetzt die kanonische Repo-Root-Skill-Datei und frischt die paket-lokale Kompatibilitätskopie auf, die installierte Harnesses nutzen. Innerhalb dieses Repos verweist das REPL-Banner Agenten auf den kanonischen Root-Skill-Pfad; nach `pip install` fällt es auf die paketierte Kopie zurück. + +--- + +## 🎬 Demos aus der Praxis + +KI-Agenten nutzen generierte CLIs, um vollständige, brauchbare Artefakte zu produzieren — ganz ohne GUI. + +### FreeCAD — Curiosity Rover via Preview, Live Preview und Trajektorie + +> **Harness:** `cli-anything-freecad` | **Preview-Stack:** `preview` + `preview live` + `trajectory.json` | **Artefakt:** Agent-gebauter Curiosity-artiger Rover + +Ein Agent baut Schritt für Schritt einen Curiosity-inspirierten Rover auf und veröffentlicht dabei echte FreeCAD-Preview-Bundles, frischt eine Live-Preview-Session auf und protokolliert die Command-zu-Preview-Historie für späteres Replay. Das resultierende Demo zeigt, wie das Artefakt vor dem finalen Showcase Stück für Stück wächst. + +

+ FreeCAD Curiosity Rover Demo angetrieben von Preview, Live Preview und Trajektorien-Historie +

+ +

+ Das README-GIF wurde aus dem vollständigen lokalen Demo-Video über einen Speed-angepassten, qualitätshohen ffmpeg-Palette-Workflow erzeugt. +

+ +### Blender — Orbital Relay Drone via Preview, Live Preview und Trajektorie + +> **Harness:** `cli-anything-blender` | **Preview-Stack:** `preview` + `preview live` + `trajectory.json` | **Artefakt:** Agent-gebaute Orbital-Relay-Drohne + +Ein Agent nutzt das Blender-Harness, um in einem echten Preview-Loop eine Hard-Surface-Orbital-Relay-Drohne wachsen zu lassen: jede Stufe pusht neue render-gestützte Bundles, die Live-Session verfolgt den aktuellen Head, und die Trajektorie verknüpft jedes Kommando mit dem passenden visuellen Stand. Das Demo endet mit der fertigen Szene, bereit für eine polierte Turntable-Aufnahme. + +

+ Blender Orbital-Relay-Drohne Demo angetrieben von Preview, Live Preview und Trajektorien-Historie +

+ +

+ Das README-GIF wurde aus dem vollständigen lokalen Demo-Video über einen Speed-angepassten, qualitätshohen ffmpeg-Palette-Workflow erzeugt. +

+ +### Draw.io — HTTPS-Handshake-Diagramm + +> **Harness:** `cli-anything-drawio` | **Zeit:** ~4 Min. | **Artefakt:** `.drawio` + `.png` + +Ein Agent erstellt von Grund auf ein vollständiges Diagramm zum Lebenszyklus einer HTTPS-Verbindung — TCP-Three-Way-Handshake, TLS-Aushandlung, verschlüsselter Datenaustausch und TCP-Four-Way-Termination — ausschließlich über CLI-Befehle. + +

+ Draw.io-CLI-Demo: Aufbau eines HTTPS-Handshake-Diagramms +

+ +
+Finales Artefakt +

+ HTTPS-Handshake-Sequenzdiagramm +

+
+ +*Beigesteuert von [@zhangxilong-43](https://github.com/zhangxilong-43)* + +### Slay the Spire II — Game-Automatisierung + +> **Harness:** `cli-anything-slay-the-spire-ii` | **Artefakt:** Automatisierte Spielsession + +Ein Agent spielt über das CLI-Harness einen kompletten Slay-the-Spire-II-Run durch — liest den Spielzustand, wählt Karten, wählt Pfade und trifft strategische Entscheidungen in Echtzeit. + +

+ Slay the Spire II CLI-Gameplay-Demo +

+ +*Beigesteuert von [@TianyuFan0504](https://github.com/TianyuFan0504)* + +### VideoCaptioner — automatisch generierte Untertitel + +> **Harness:** `cli-anything-videocaptioner` | **Artefakt:** Untertitelte Videoframes + +Ein Agent nutzt das VideoCaptioner-CLI, um automatisch gestaltete Untertitel zu generieren und in Videoinhalte einzublenden — inklusive zweisprachiger Text-Wiedergabe und individueller Formatierung. + + + + + + + + + + +
Sub ASub B
Videoframe vor dem CaptioningVideoframe nach dem Captioning
+ +*Beigesteuert von [@WEIFENG2333](https://github.com/WEIFENG2333)* + +*Weitere CLI-Demos folgen in Kürze.* + +--- + +## 🎬 Demonstrationen + +### 🎯 Universell einsetzbar +CLI-Anything funktioniert mit jeder Software, die eine Codebase hat — keine Domänen-Beschränkungen, keine Architektur-Einschränkungen. + +### 🏭 Tests auf Professional-Niveau +Getestet an 12 vielfältigen, anspruchsvollen Anwendungen, die für KI-Agenten zuvor unzugänglich waren — von Kreativ-, Produktivitäts-, Kommunikations-, Diagramm- und AI-Content-Bereichen bis hin zu GPU-Debugging. + +### 🎨 Breite Domänen-Abdeckung +Von Kreativ-Workflows (Bildbearbeitung, 3D-Modeling, Vektorgrafik) bis zu Produktionstools (Audio, Office, Live-Streaming, Video-Editing). + +### ✅ Vollständige CLI-Generierung +Für jede Anwendung wird ein komplettes, produktionsreifes CLI-Interface erzeugt — keine Demo, sondern umfassender Tool-Zugriff, der den vollen Funktionsumfang erhält. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SoftwareDomäneCLI-BefehlBackendTests
🎨 GIMPBildbearbeitungcli-anything-gimpPillow + GEGL/Script-Fu✅ 107
🧊 Blender3D-Modeling & Renderingcli-anything-blenderbpy (Python-Scripting)✅ 208
✏️ InkscapeVektorgrafikcli-anything-inkscapeDirekte SVG/XML-Manipulation✅ 202
🎵 AudacityAudio-Produktioncli-anything-audacityPython wave + sox✅ 161
📄 LibreOfficeOffice-Suite (Writer, Calc, Impress)cli-anything-libreofficeODF-Generierung + Headless-LO✅ 158
📹 OBS StudioLive-Streaming & Aufnahmecli-anything-obs-studioJSON-Szenen + obs-websocket✅ 153
🎞️ KdenliveVideo-Editingcli-anything-kdenliveMLT XML + melt-Renderer✅ 155
🎬 ShotcutVideo-Editingcli-anything-shotcutDirektes MLT XML + melt✅ 154
📞 ZoomVideokonferenzencli-anything-zoomZoom REST API (OAuth2)✅ 22
📐 Draw.ioDiagrammecli-anything-drawiomxGraph XML + draw.io CLI✅ 138
✨ AnyGenAI-Content-Generationcli-anything-anygenAnyGen REST API (anygen.io)✅ 50
🟩 Nsight Graphics CLIGPU-Debugging & -Profilingcli-anything-nsight-graphicsOffizielle ngfx-/ngfx-capture-Orchestrierung + GPU-Trace-Summary✅ 40
Gesamt✅ 1.547
+ +> **Alle 1.547 Tests grün — 100 %** — 1.108 Unit-Tests + 439 End-to-End-Tests. + +--- + +## 📊 Testergebnisse + +Jedes CLI-Harness durchläuft eine strenge, mehrschichtige Test-Pipeline für Produktions-Zuverlässigkeit: + +| Schicht | Was getestet wird | Beispiel | +|-------|---------------|---------| +| **Unit-Tests** | Alle Kern-Funktionen isoliert, mit synthetischen Daten | `test_core.py` — Projekt erstellen, Layer-Operationen, Filter-Parameter | +| **E2E-Tests (nativ)** | Projektdatei-Generierungs-Pipelines | Gültige ODF-ZIP-Struktur, korrektes MLT-XML, wohlgeformtes SVG | +| **E2E-Tests (echtes Backend)** | Echte Software-Aufrufe + Output-Verifikation | LibreOffice → PDF mit `%PDF-`-Magic-Bytes, Blender → gerendertes PNG | +| **CLI-Subprozess-Tests** | Installierte Befehle via `subprocess.run` | `cli-anything-gimp --json project new` → gültiges JSON-Output | + +``` +================================ Test-Zusammenfassung ================================ +gimp 107 passed ✅ (64 unit + 43 e2e) +blender 208 passed ✅ (150 unit + 58 e2e) +inkscape 202 passed ✅ (148 unit + 54 e2e) +audacity 161 passed ✅ (107 unit + 54 e2e) +libreoffice 158 passed ✅ (89 unit + 69 e2e) +obs-studio 153 passed ✅ (116 unit + 37 e2e) +kdenlive 155 passed ✅ (111 unit + 44 e2e) +shotcut 154 passed ✅ (110 unit + 44 e2e) +zoom 22 passed ✅ (22 unit + 0 e2e) +drawio 138 passed ✅ (116 unit + 22 e2e) +anygen 50 passed ✅ (40 unit + 10 e2e) +nsight-graphics 40 passed ✅ (36 unit + 4 e2e) +────────────────────────────────────────────────────────────────────────────── +Gesamt 1.547 passed ✅ 100 % Pass-Rate +``` + +--- + +## 🏗️ Die Architektur von CLI-Anything + +

+ CLI-Anything-Architektur +

+ +### 🎯 Zentrale Design-Prinzipien + +1. **Echte Software-Integration** — CLIs erzeugen gültige Projektdateien (ODF, MLT XML, SVG) und delegieren das Rendering an die echte Anwendung. **Wir bauen strukturierte Schnittstellen zur Software, keinen Ersatz für sie**. + +2. **Flexibles Interaktionsmodell** — alle CLIs laufen im Dual-Mode: zustandsbehaftete REPL für interaktive Agent-Sessions + Sub-Command-Interface für Scripting und Pipelines. **Befehl ohne Argumente ausführen → REPL-Modus**. + +3. **Einheitliche User-Experience** — alle generierten CLIs teilen sich dieselbe REPL-Oberfläche (`repl_skin.py`) mit Brand-Banner, gestaltetem Prompt, Command-History, Fortschrittsanzeigen und standardisiertem Formatting. + +4. **Agent-Native Design** — jeder Befehl hat einen eingebauten `--json`-Flag für maschinen-strukturierte Daten, dazu menschenlesbare Tabellen für interaktive Nutzung. **Agenten entdecken Funktionen über das Standard-`--help` und `which`**. + +5. **Keine Kompromisse bei Abhängigkeiten** — echte Software ist Pflichtvoraussetzung — keine Fallbacks, keine Graceful Degradation. **Fehlt das Backend, scheitern die Tests statt sie zu überspringen — so bleiben echte Funktionen garantiert.** + +--- + +## 📂 Projektstruktur + +``` +cli-anything/ +├── 📄 README.md # Englisches README +├── 📁 assets/ # Bilder und Medien +│ ├── icon.png # Projekt-Icon +│ └── teaser.png # Teaser-Bild +│ +├── 🔌 cli-anything-plugin/ # Claude-Code-Plugin +│ ├── HARNESS.md # Methodik-SOP (Single Source of Truth) +│ ├── README.md # Plugin-Dokumentation +│ ├── QUICKSTART.md # 5-Minuten-Einstieg +│ ├── PUBLISHING.md # Publishing-Guide +│ ├── repl_skin.py # Vereinheitlichte REPL-Oberfläche +│ ├── commands/ # Plugin-Command-Definitionen +│ │ ├── cli-anything.md # Haupt-Build-Befehl +│ │ ├── refine.md # Coverage bestehender Harnesses erweitern +│ │ ├── test.md # Test-Runner +│ │ └── validate.md # Standard-Validierung +│ └── scripts/ +│ └── setup-cli-anything.sh # Setup-Skript +│ +├── 🤖 codex-skill/ # Codex-Skill-Einstiegspunkt +├── 🎨 gimp/agent-harness/ # GIMP CLI (107 Tests) +├── 🧊 blender/agent-harness/ # Blender CLI (208 Tests) +├── ✏️ inkscape/agent-harness/ # Inkscape CLI (202 Tests) +├── 🎵 audacity/agent-harness/ # Audacity CLI (161 Tests) +├── 📄 libreoffice/agent-harness/ # LibreOffice CLI (158 Tests) +├── 📹 obs-studio/agent-harness/ # OBS Studio CLI (153 Tests) +├── 🎞️ kdenlive/agent-harness/ # Kdenlive CLI (155 Tests) +├── 🎬 shotcut/agent-harness/ # Shotcut CLI (154 Tests) +├── 📞 zoom/agent-harness/ # Zoom CLI (22 Tests) +├── 📐 drawio/agent-harness/ # Draw.io CLI (138 Tests) +├── ✨ anygen/agent-harness/ # AnyGen CLI (50 Tests) +└── 🟩 nsight-graphics/agent-harness/ # Nsight Graphics CLI (40 Tests) +``` + +Jedes `agent-harness/` enthält ein pip-installierbares Python-Paket unter `cli_anything./` mit Click-CLI, Core-Modulen, Utilities (inklusive `repl_skin.py` und Backend-Wrappern) und einer umfassenden Test-Suite. + +--- + +## 🎯 Plugin-Befehle + +| Befehl | Beschreibung | +|---------|-------------| +| `/cli-anything ` | Komplettes CLI-Harness bauen — alle 7 Phasen | +| `/cli-anything:refine [Fokus]` | Bestehendes Harness verfeinern — Gap-Analyse, mehr Coverage | +| `/cli-anything:test ` | Tests laufen lassen und TEST.md mit Ergebnissen aktualisieren | +| `/cli-anything:validate ` | Gegen den HARNESS.md-Standard validieren | + +### Beispiele + +```bash +# Komplettes CLI für GIMP aus lokaler Quelle bauen +/cli-anything /home/user/gimp + +# Aus einem GitHub-Repo bauen +/cli-anything https://github.com/blender/blender + +# Bestehendes Harness verfeinern — breite Gap-Analyse +/cli-anything:refine /home/user/gimp + +# Mit einem spezifischen Fokus verfeinern +/cli-anything:refine /home/user/shotcut "Video-in-Video- und Picture-in-Picture-Compositing" + +# Tests laufen lassen und TEST.md aktualisieren +/cli-anything:test /home/user/inkscape + +# Gegen den HARNESS.md-Standard validieren +/cli-anything:validate /home/user/audacity +``` + +--- + +## 🎮 Demo: Generiertes CLI im Einsatz + +Beispiel dafür, was ein Agent mit `cli-anything-libreoffice` tun kann: + +```bash +# Neues Writer-Dokument anlegen +$ cli-anything-libreoffice document new -o report.json --type writer +✓ Writer-Dokument erstellt: report.json + +# Inhalt hinzufügen +$ cli-anything-libreoffice --project report.json writer add-heading -t "Q1-Bericht" --level 1 +✓ Überschrift hinzugefügt: "Q1-Bericht" + +$ cli-anything-libreoffice --project report.json writer add-table --rows 4 --cols 3 +✓ 4×3-Tabelle hinzugefügt + +# In echtes PDF exportieren via LibreOffice headless +$ cli-anything-libreoffice --project report.json export render output.pdf -p pdf --overwrite +✓ Export abgeschlossen: output.pdf (42.831 Bytes) via libreoffice-headless + +# JSON-Modus für Agenten +$ cli-anything-libreoffice --json document info --project report.json +{ + "name": "Q1 Report", + "type": "writer", + "pages": 1, + "elements": 2, + "modified": true +} +``` + +### REPL-Modus + +``` +$ cli-anything-blender +╔══════════════════════════════════════════╗ +║ cli-anything-blender v1.0.0 ║ +║ Blender CLI for AI Agents ║ +╚══════════════════════════════════════════╝ + +blender> scene new --name ProductShot +✓ Szene erstellt: ProductShot + +blender[ProductShot]> object add-mesh --type cube --location 0 0 1 +✓ Mesh hinzugefügt: Cube bei (0, 0, 1) + +blender[ProductShot]*> render execute --output render.png --engine CYCLES +✓ Rendering abgeschlossen: render.png (1920×1080, 2,3 MB) via blender --background + +blender[ProductShot]> exit +Goodbye! 👋 +``` + +--- + +## 📖 Das Standard-Playbook: HARNESS.md + +`HARNESS.md` ist die definitive SOP, um jede Software über automatische CLI-Generierung agent-zugänglich zu machen. + +Sie kodifiziert die bewährten Muster und Methoden, die im Zuge der automatischen Generierung herausgearbeitet wurden. + +Das Playbook bündelt die zentralen Erkenntnisse aus dem Bau von 11 unterschiedlichen, produktionsreifen Harnesses. + +### Wichtige Lessons-Learned + +| Lektion | Beschreibung | +|--------|-------------| +| **Echte Software nutzen** | CLIs müssen die echte Anwendung zum Rendern aufrufen. Nicht Pillow statt GIMP, kein eigener Renderer statt Blender. Gültige Projektdateien generieren → echtes Backend anstoßen. | +| **Render-Lücke** | GUI-Apps wenden Effekte beim Rendern an. Wenn das CLI Projektdateien manipuliert, aber ein naiver Export-Tool verwendet, gehen Effekte stillschweigend verloren. Lösung: nativer Renderer → Filter-Konvertierung → Render-Skript. | +| **Filter-Konvertierung** | Beim Mapping von Effekten zwischen Formaten (MLT → ffmpeg) auf das achten: doppelte Filter zusammenführen, verschachtelte Stream-Reihenfolge, Parameter-Raum-Unterschiede, nicht abbildbare Effekte. | +| **Timecode-Präzision** | Nicht-ganzzahlige Frameraten (29,97 fps) erzeugen kumulative Rundungsfehler. `round()` statt `int()`, Integer-Arithmetik für die Anzeige, ±1-Frame-Toleranz in Tests. | +| **Output-Validierung** | Exit-Code 0 ≠ erfolgreicher Export. Stattdessen prüfen: Magic-Bytes, ZIP-/OOXML-Struktur, Pixel-Analyse, Audio-RMS-Pegel, Längen-Checks. | + +> Vollständige Methodik hier: [`cli-anything-plugin/HARNESS.md`](cli-anything-plugin/HARNESS.md) + +--- + +## 📦 Installation & Nutzung + +### Für CLI-Hub-Nutzer + +```bash +# Package Manager installieren +pip install cli-anything-hub + +# CLIs browsen, suchen, inspizieren und installieren +cli-hub list +cli-hub search +cli-hub info +cli-hub install + +# Installierte CLIs verwalten +cli-hub update +cli-hub uninstall +cli-hub launch [args...] +``` + +### Für Plugin-Nutzer (Claude Code) + +```bash +# Marketplace hinzufügen und installieren (empfohlen) +/plugin marketplace add HKUDS/CLI-Anything +/plugin install cli-anything + +# CLI für eine Software mit Codebase bauen +/cli-anything +``` + +### Für die generierten CLIs + +```bash +# Generiertes CLI installieren +cd /agent-harness +pip install -e . + +# Verifizieren +which cli-anything- + +# Nutzen +cli-anything- --help +cli-anything- # REPL starten +cli-anything- --json # JSON-Output für Agenten +``` + +### Tests ausführen + +```bash +# Tests für ein bestimmtes CLI laufen lassen +cd /agent-harness +python3 -m pytest cli_anything//tests/ -v + +# Force-Installed-Modus (empfohlen für Verifikation) +CLI_ANYTHING_FORCE_INSTALLED=1 python3 -m pytest cli_anything//tests/ -v -s +``` + +--- + +## 🤝 Mitwirken + +Beiträge sind willkommen! CLI-Anything ist auf Erweiterbarkeit ausgelegt: + +- **Neue Software-Targets** — generiere mit dem Plugin ein CLI für eine beliebige Codebase und reiche das Harness über [`cli-anything-plugin/PUBLISHING.md`](cli-anything-plugin/PUBLISHING.md) ein. +- **Methodik-Verbesserungen** — PRs gegen `HARNESS.md` mit neuen Lessons-Learned +- **Plugin-Erweiterungen** — neue Befehle, bessere Phasen, robustere Validierung +- **Test-Coverage** — mehr E2E-Szenarien, Edge-Cases, Workflow-Tests + +### Einschränkungen + +- **Starkes Foundation-Modell nötig** — CLI-Anything braucht ein Modell der Frontier-Klasse (z. B. Claude Opus 4.6, Claude Sonnet 4.6, GPT-5.4), damit die Harness-Generierung zuverlässig ist. Schwächere oder kleinere Modelle erzeugen ggf. unvollständige oder fehlerhafte CLIs, die viel manuelle Nacharbeit erfordern. +- **Abhängig von verfügbarem Quellcode** — die 7-Phasen-Pipeline analysiert und generiert auf Basis des Source-Codes. Liefert die Zielsoftware nur kompilierte Binaries (Decompilation nötig), sinken Qualität und Abdeckung des Harness deutlich. +- **Iterative Verfeinerung kann nötig sein** — ein einzelner `/cli-anything`-Lauf deckt nicht zwangsläufig alle Funktionen vollständig ab. Ein oder mehrere `/refine`-Läufe sind oft erforderlich, um Performance und Coverage des CLIs auf Produktionsniveau zu bringen. + +### Roadmap + +- [ ] Unterstützung für weitere Anwendungs-Kategorien (CAD, DAW, IDE, EDA, Science-Tools) +- [ ] Benchmark-Suite für Agent-Task-Completion-Rates +- [ ] Community-beigesteuerte CLI-Harnesses für In-House- bzw. Custom-Software +- [ ] Integration mit zusätzlichen Agent-Frameworks neben Claude Code +- [ ] Unterstützung, Closed-Source-Software und Web-Service-APIs in CLIs zu verpacken +- [ ] Zusätzlich zum CLI eine `SKILL.md` für Agent-Skill-Discovery und -Orchestrierung mitgenerieren + +--- + +## 📖 Dokumentation + +| Dokument | Beschreibung | +|----------|-------------| +| [`cli-anything-plugin/HARNESS.md`](cli-anything-plugin/HARNESS.md) | Methodik-SOP — Single Source of Truth | +| [`cli-anything-plugin/README.md`](cli-anything-plugin/README.md) | Plugin-Dokumentation — Befehle, Optionen, Phasen | +| [`cli-anything-plugin/QUICKSTART.md`](cli-anything-plugin/QUICKSTART.md) | 5-Minuten-Einstieg | +| [`cli-anything-plugin/PUBLISHING.md`](cli-anything-plugin/PUBLISHING.md) | Distribution- und Publishing-Guide | + +Jedes generierte Harness enthält zusätzlich: +- `.md` — die anwendungsspezifische Architektur-SOP +- `tests/TEST.md` — Testplan und Ergebnis-Dokumentation + +--- + +## ⭐ Star-Verlauf + +Wenn CLI-Anything dir hilft, deine Software agent-native zu machen, freuen wir uns über einen Stern! ⭐ + + + +--- + +## 📄 Lizenz + +Apache License 2.0 — frei nutzbar, modifizierbar und verteilbar. + +--- + +
+ +**CLI-Anything** — *Jede Software mit Codebase agent-native machen.* + +Methodik für das Zeitalter der KI-Agenten | 11 professionelle Software-Demos | 1.508 bestandene Tests + +
+ +CLI-Anything-Icon + +
+ +

+ Danke, dass du dir CLI-Anything ansiehst ✨

+ Views +

diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..5135f80 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,68 @@ +# Security Policy + +> **Note:** This document is for human contributors and security reviewers. +> It is NOT part of the CLI generation methodology — agents should follow +> HARNESS.md only. + +## Threat Model + +CLI-Anything bridges AI agents and real desktop software. Unlike traditional +CLIs where a human reviews every command, **an AI agent may autonomously +construct and execute commands** based on untrusted input (user prompts, +uploaded files, or other agents' output). This makes input validation +critical — a prompt-injected agent could pass crafted arguments to software +backends (melt, GIMP Script-Fu, LibreOffice, etc.). + +Key attack surfaces: + +| Surface | Risk | Mitigation | +|---------|------|------------| +| Subprocess arguments | Malicious codec names, paths, or filter params passed to melt/ffmpeg/gimp | Allowlist validation before subprocess calls | +| Script-Fu injection | User-controlled strings embedded in GIMP batch scripts | `_script_fu_escape()` in gimp_backend.py | +| XML/SVG content | User text injected into MLT XML, SVG, or Draw.io files | `xml_escape()` / ElementTree auto-escaping | +| File path traversal | Agent-controlled output paths writing to arbitrary locations | `os.path.abspath()` normalization | +| Credential exposure | API keys stored in plaintext config files | File permissions set to `0o600` | + +## Reporting a Vulnerability + +If you discover a security vulnerability, please report it responsibly: + +1. **Do NOT open a public GitHub issue.** +2. Email the maintainers or use GitHub's private vulnerability reporting: + **Security** tab > **Report a vulnerability**. +3. Include: affected file(s), reproduction steps, and potential impact. +4. We aim to acknowledge reports within 48 hours and release a fix within + 7 days for critical issues. + +## Codec Allowlists (Breaking Change) + +The kdenlive and shotcut melt backends validate `vcodec` and `acodec` +parameters against `ALLOWED_VCODECS` / `ALLOWED_ACODECS` frozensets. +Codecs not in the allowlist will raise `ValueError`. + +The allowlists cover all codecs used by existing export presets plus +common hardware-accelerated variants. If your workflow requires an +unlisted codec, extend the frozensets in `melt_backend.py`: + +```python +from cli_anything.kdenlive.utils.melt_backend import ALLOWED_VCODECS +# ALLOWED_VCODECS is a frozenset — create a new one to extend +ALLOWED_VCODECS = ALLOWED_VCODECS | {"my_custom_codec"} +``` + +Similarly, `extra_args` cannot contain `vcodec=`, `acodec=`, or +`-consumer` prefixes — use the dedicated function parameters instead. + +## Security Guidelines for Harness Developers + +When building a new CLI harness, follow these rules: + +1. **Never use `shell=True`** in `subprocess.run()` — always pass arguments + as a list. +2. **Validate all subprocess arguments** against an allowlist. Do not pass + user-controlled strings directly to external tools. +3. **Escape user content** before embedding it in scripts (Script-Fu, Python, + Lua) or structured formats (XML, SVG, HTML). +4. **Use `os.path.abspath()`** on all file paths and consider resolving + symlinks with `os.path.realpath()` for sensitive write operations. +5. **Never log or echo API keys** in error messages or JSON output. diff --git a/adguardhome/agent-harness/.gitignore b/adguardhome/agent-harness/.gitignore new file mode 100644 index 0000000..ea85e27 --- /dev/null +++ b/adguardhome/agent-harness/.gitignore @@ -0,0 +1,11 @@ +.venv/ +__pycache__/ +*.pyc +*.pyo +*.egg-info/ +.pytest_cache/ +.fastembed_cache/ +.leann/ +*.egg-info/ +dist/ +build/ diff --git a/adguardhome/agent-harness/ADGUARDHOME.md b/adguardhome/agent-harness/ADGUARDHOME.md new file mode 100644 index 0000000..3f06485 --- /dev/null +++ b/adguardhome/agent-harness/ADGUARDHOME.md @@ -0,0 +1,66 @@ +# AdGuardHome - CLI Harness SOP + +## Overview + +AdGuardHome is a DNS-based ad blocker and privacy protection server written in Go. +It exposes a REST HTTP API with 58 endpoints organized in 14 tag groups, secured with HTTP Basic Auth. + +**Real software:** The running AdGuardHome HTTP API (not a binary to invoke directly). +**CLI role:** Generate structured commands - call the real API - verify responses. + +## Architecture + +- **API base:** `http://:/control/` +- **Auth:** HTTP Basic Auth (`Authorization: Basic base64(user:pass)`) +- **Port:** 3000 by default +- **OpenAPI spec:** `openapi/openapi.yaml` in the AdGuardHome source + +## API Tag Groups + +| Group | Description | Key Endpoints | +|-------|-------------|---------------| +| `global` | Server settings and controls | `/status`, `/version`, `/restart` | +| `filtering` | Rule-based filtering | `/filtering/status`, `/filtering/add_url`, `/filtering/remove_url` | +| `blocked_services` | Block service categories | `/blocked_services/get`, `/blocked_services/set` | +| `clients` | Known clients | `/clients`, `/clients/add`, `/clients/delete` | +| `stats` | DNS query statistics | `/stats`, `/stats_reset`, `/stats_config` | +| `log` | Query log | `/querylog`, `/querylog_config`, `/querylog_clear` | +| `dhcp` | Built-in DHCP server | `/dhcp/status`, `/dhcp/leases`, `/dhcp/set_config` | +| `rewrite` | DNS rewrites | `/rewrite/list`, `/rewrite/add`, `/rewrite/delete` | +| `parental` | Adult content blocking | `/parental/status`, `/parental/enable`, `/parental/disable` | +| `safebrowsing` | Malware/phishing blocking | `/safebrowsing/status`, `/safebrowsing/enable`, `/safebrowsing/disable` | +| `safesearch` | Safe search enforcement | `/safesearch/status`, `/safesearch/enable`, `/safesearch/disable` | +| `tls` | HTTPS/DoH/DoT settings | `/tls/status`, `/tls/configure`, `/tls/validate` | + +## CLI Command Map + +``` +cli-anything-adguardhome +├── config show / save / test +├── server status / version / restart +├── filter list / add / remove / enable / disable / refresh / status / toggle +├── blocking parental status/enable/disable +│ safebrowsing status/enable/disable +│ safesearch status/enable/disable +├── blocked-services list / set +├── clients list / add / remove / show +├── stats show / reset / config +├── log show / config / clear +├── rewrite list / add / remove +├── dhcp status / leases / add-static / remove-static +└── tls status +``` + +## Connection Config + +Settings resolved in order: +1. CLI flags (`--host`, `--port`, `--username`, `--password`) +2. Environment vars (`AGH_HOST`, `AGH_PORT`, `AGH_USERNAME`, `AGH_PASSWORD`) +3. Config file (`~/.config/cli-anything-adguardhome.json`) +4. Defaults: `localhost:3000` + +## Testing Strategy + +- **Unit tests:** Mock HTTP calls via `unittest.mock` - no real AdGuardHome needed +- **E2E tests:** Spin up `adguard/adguardhome` via Docker on port 3001 for isolation +- **Subprocess tests:** `_resolve_cli("cli-anything-adguardhome")` tests the installed CLI binary diff --git a/adguardhome/agent-harness/cli_anything/adguardhome/README.md b/adguardhome/agent-harness/cli_anything/adguardhome/README.md new file mode 100644 index 0000000..1177678 --- /dev/null +++ b/adguardhome/agent-harness/cli_anything/adguardhome/README.md @@ -0,0 +1,71 @@ +# cli-anything-adguardhome + +CLI harness for AdGuardHome - control your ad blocker from the command line or via agents. + +## Prerequisites + +AdGuardHome must be running. Install: + +```bash +# Linux - native +curl -s -S -L https://raw.githubusercontent.com/AdguardTeam/AdGuardHome/master/scripts/install.sh | sh -s -- -v + +# Docker +docker run --name adguardhome -p 3000:3000 adguard/adguardhome +``` + +## Installation + +```bash +cd agent-harness +pip install -e . +cli-anything-adguardhome --help +``` + +## Configuration + +```bash +export AGH_HOST=localhost +export AGH_PORT=3000 +export AGH_USERNAME=admin +export AGH_PASSWORD=secret + +# Or save to config file +cli-anything-adguardhome --host localhost --port 3000 --username admin --password secret config save +``` + +## Usage + +```bash +# Interactive REPL (default) +cli-anything-adguardhome + +# One-shot commands +cli-anything-adguardhome server status +cli-anything-adguardhome filter list +cli-anything-adguardhome --json stats show + +# Filtering +cli-anything-adguardhome filter add --url https://somehost.com/list.txt --name "My List" +cli-anything-adguardhome filter refresh + +# DNS rewrites +cli-anything-adguardhome rewrite add --domain "myserver.local" --answer "192.168.1.50" +cli-anything-adguardhome rewrite list + +# Clients +cli-anything-adguardhome clients add --name "My PC" --ip 192.168.1.100 + +# Stats +cli-anything-adguardhome stats show +cli-anything-adguardhome stats reset +``` + +## Tests + +```bash +cd agent-harness +python3 -m pytest cli_anything/adguardhome/tests/test_core.py -v +python3 -m pytest cli_anything/adguardhome/tests/test_full_e2e.py -v -s +CLI_ANYTHING_FORCE_INSTALLED=1 python3 -m pytest cli_anything/adguardhome/tests/ -v -s +``` diff --git a/adguardhome/agent-harness/cli_anything/adguardhome/__init__.py b/adguardhome/agent-harness/cli_anything/adguardhome/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/adguardhome/agent-harness/cli_anything/adguardhome/__main__.py b/adguardhome/agent-harness/cli_anything/adguardhome/__main__.py new file mode 100644 index 0000000..e36dc8e --- /dev/null +++ b/adguardhome/agent-harness/cli_anything/adguardhome/__main__.py @@ -0,0 +1,5 @@ +"""Enable python -m cli_anything.adguardhome""" +from cli_anything.adguardhome.adguardhome_cli import main + +if __name__ == "__main__": + main() diff --git a/adguardhome/agent-harness/cli_anything/adguardhome/adguardhome_cli.py b/adguardhome/agent-harness/cli_anything/adguardhome/adguardhome_cli.py new file mode 100644 index 0000000..9b81b55 --- /dev/null +++ b/adguardhome/agent-harness/cli_anything/adguardhome/adguardhome_cli.py @@ -0,0 +1,676 @@ +"""cli-anything-adguardhome - CLI harness for AdGuardHome.""" + +import json +import shlex +import sys +from pathlib import Path + +import click + +from cli_anything.adguardhome.core import blocking as blocking_core +from cli_anything.adguardhome.core import clients as clients_core +from cli_anything.adguardhome.core import dhcp as dhcp_core +from cli_anything.adguardhome.core import filtering as filtering_core +from cli_anything.adguardhome.core import log as log_core +from cli_anything.adguardhome.core import project +from cli_anything.adguardhome.core import rewrite as rewrite_core +from cli_anything.adguardhome.core import server as server_core +from cli_anything.adguardhome.core import stats as stats_core +from cli_anything.adguardhome.utils.adguardhome_backend import AdGuardHomeClient +from cli_anything.adguardhome.utils.repl_skin import ReplSkin + +CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]} + + +def make_client(ctx: click.Context) -> AdGuardHomeClient: + obj = ctx.obj + return AdGuardHomeClient( + host=obj["host"], + port=obj["port"], + username=obj["username"], + password=obj["password"], + https=obj.get("use_https", False), + ) + + +def output(data, as_json: bool) -> None: + if as_json: + click.echo(json.dumps(data, indent=2, default=str)) + elif isinstance(data, dict): + for k, v in data.items(): + click.echo(f"{k}: {v}") + elif isinstance(data, list): + for item in data: + if isinstance(item, dict): + click.echo(json.dumps(item, default=str)) + else: + click.echo(str(item)) + else: + click.echo(str(data)) + + +# --------------------------------------------------------------------------- +# Root group +# --------------------------------------------------------------------------- + +@click.group(context_settings=CONTEXT_SETTINGS, invoke_without_command=True) +@click.option("--host", default=None, help="AdGuardHome hostname/IP") +@click.option("--port", default=None, type=int, help="AdGuardHome port (default 3000)") +@click.option("--username", default=None, help="Basic Auth username") +@click.option("--password", default=None, help="Basic Auth password") +@click.option("--config", "config_path", default=None, type=click.Path(), + help="Path to config file") +@click.option("--https", "use_https", is_flag=True, default=False, + help="Use HTTPS (auto-detected for port 443)") +@click.option("--json", "as_json", is_flag=True, default=False, + help="Output as JSON") +@click.pass_context +def cli(ctx: click.Context, host, port, username, password, config_path, use_https, as_json): + """cli-anything-adguardhome - control AdGuardHome from the command line.""" + ctx.ensure_object(dict) + + config_path_obj = Path(config_path) if config_path else None + cfg = project.load_config(config_path_obj) + ctx.obj["host"] = host or cfg["host"] + ctx.obj["port"] = port or cfg["port"] + ctx.obj["username"] = username or cfg["username"] + ctx.obj["password"] = password or cfg["password"] + ctx.obj["use_https"] = use_https or cfg.get("https", False) + ctx.obj["as_json"] = as_json + ctx.obj["config_path"] = config_path_obj + + if ctx.invoked_subcommand is None: + ctx.invoke(repl) + + +def main(): + cli(obj={}) + + +# --------------------------------------------------------------------------- +# REPL +# --------------------------------------------------------------------------- + +@cli.command(hidden=True) +@click.pass_context +def repl(ctx: click.Context): + """Interactive REPL mode.""" + skin = ReplSkin("adguardhome", version="1.0.0") + skin.print_banner() + + host = ctx.obj["host"] + port = ctx.obj["port"] + skin.info(f"Connecting to {host}:{port}") + + pt_session = skin.create_prompt_session() + + while True: + try: + line = skin.get_input(pt_session, project_name=f"{host}:{port}") + except (EOFError, KeyboardInterrupt): + break + + line = line.strip() + if not line: + continue + if line in ("exit", "quit"): + break + if line == "help": + skin.help({ + "server status/version/restart": "Server management", + "filter list/add/remove/enable/disable/refresh/status/toggle": "Filtering", + "blocking parental/safebrowsing/safesearch status/enable/disable": "Blocking", + "blocked-services list/set": "Blocked services", + "clients list/add/remove/show": "Client management", + "stats show/reset/config": "Statistics", + "log show/config/clear": "Query log", + "rewrite list/add/remove": "DNS rewrites", + "dhcp status/leases/add-static/remove-static": "DHCP server", + "tls status": "TLS configuration", + "config show/save/test": "Connection config", + }) + continue + + try: + args = shlex.split(line) + cli.main(args=args, obj=dict(ctx.obj), standalone_mode=False) + except click.exceptions.UsageError as e: + skin.error(str(e)) + except RuntimeError as e: + skin.error(str(e)) + except SystemExit: + pass + except Exception as e: + skin.error(f"Unexpected error: {e}") + + skin.print_goodbye() + + +# --------------------------------------------------------------------------- +# config +# --------------------------------------------------------------------------- + +@cli.group() +@click.pass_context +def config(ctx: click.Context): + """Connection configuration.""" + + +@config.command("show") +@click.pass_context +def config_show(ctx: click.Context): + """Show current connection settings.""" + obj = ctx.obj + data = { + "host": obj["host"], + "port": obj["port"], + "username": obj["username"], + "password": "***" if obj["password"] else "", + } + output(data, obj["as_json"]) + + +@config.command("save") +@click.pass_context +def config_save(ctx: click.Context): + """Save connection settings to config file.""" + obj = ctx.obj + path = project.save_config( + host=obj["host"], port=obj["port"], + username=obj["username"], password=obj["password"], + https=obj.get("use_https", False), + config_path=obj.get("config_path"), + ) + result = {"saved": str(path)} + output(result, obj["as_json"]) + + +@config.command("test") +@click.pass_context +def config_test(ctx: click.Context): + """Test connection to AdGuardHome.""" + client = make_client(ctx) + data = server_core.get_status(client) + result = {"connected": True, "host": ctx.obj["host"], "port": ctx.obj["port"], **data} + output(result, ctx.obj["as_json"]) + + +# --------------------------------------------------------------------------- +# server +# --------------------------------------------------------------------------- + +@cli.group("server") +@click.pass_context +def server_(ctx: click.Context): + """Server management.""" + + +# Rename to avoid shadowing the module + +@server_.command("status") +@click.pass_context +def server_status(ctx: click.Context): + """Show server status.""" + client = make_client(ctx) + data = server_core.get_status(client) + output(data, ctx.obj["as_json"]) + + +@server_.command("version") +@click.pass_context +def server_version(ctx: click.Context): + """Show AdGuardHome version.""" + client = make_client(ctx) + data = server_core.get_version(client) + output(data, ctx.obj["as_json"]) + + +@server_.command("restart") +@click.pass_context +def server_restart(ctx: click.Context): + """Restart AdGuardHome.""" + client = make_client(ctx) + data = server_core.restart(client) + output(data or {"restarted": True}, ctx.obj["as_json"]) + + +# --------------------------------------------------------------------------- +# filter +# --------------------------------------------------------------------------- + +@cli.group("filter") +@click.pass_context +def filter_(ctx: click.Context): + """Filtering rules management.""" + + +@filter_.command("list") +@click.pass_context +def filter_list(ctx: click.Context): + """List all filter subscriptions.""" + client = make_client(ctx) + data = filtering_core.get_status(client) + output(data, ctx.obj["as_json"]) + + +@filter_.command("status") +@click.pass_context +def filter_status(ctx: click.Context): + """Show filtering enabled/disabled state.""" + client = make_client(ctx) + data = filtering_core.get_status(client) + result = {"enabled": data.get("enabled"), "filters_count": len(data.get("filters", []))} + output(result, ctx.obj["as_json"]) + + +@filter_.command("toggle") +@click.argument("state", type=click.Choice(["on", "off"])) +@click.pass_context +def filter_toggle(ctx: click.Context, state: str): + """Enable or disable filtering globally.""" + client = make_client(ctx) + data = filtering_core.set_enabled(client, state == "on") + output(data or {"filtering_enabled": state == "on"}, ctx.obj["as_json"]) + + +@filter_.command("add") +@click.option("--url", required=True, help="Filter list URL") +@click.option("--name", required=True, help="Filter name") +@click.option("--whitelist", is_flag=True, default=False) +@click.pass_context +def filter_add(ctx: click.Context, url: str, name: str, whitelist: bool): + """Add a new filter subscription.""" + client = make_client(ctx) + data = filtering_core.add_filter(client, url=url, name=name, whitelist=whitelist) + output(data or {"added": True, "url": url, "name": name}, ctx.obj["as_json"]) + + +@filter_.command("remove") +@click.option("--url", required=True, help="Filter list URL to remove") +@click.option("--whitelist", is_flag=True, default=False) +@click.pass_context +def filter_remove(ctx: click.Context, url: str, whitelist: bool): + """Remove a filter subscription.""" + client = make_client(ctx) + data = filtering_core.remove_filter(client, url=url, whitelist=whitelist) + output(data or {"removed": True, "url": url}, ctx.obj["as_json"]) + + +@filter_.command("enable") +@click.option("--url", required=True) +@click.option("--name", required=True) +@click.option("--whitelist", is_flag=True, default=False) +@click.pass_context +def filter_enable(ctx: click.Context, url: str, name: str, whitelist: bool): + """Enable a filter subscription.""" + client = make_client(ctx) + data = filtering_core.set_filter_url(client, url=url, name=name, enabled=True, + whitelist=whitelist) + output(data or {"enabled": True, "url": url}, ctx.obj["as_json"]) + + +@filter_.command("disable") +@click.option("--url", required=True) +@click.option("--name", required=True) +@click.option("--whitelist", is_flag=True, default=False) +@click.pass_context +def filter_disable(ctx: click.Context, url: str, name: str, whitelist: bool): + """Disable a filter subscription.""" + client = make_client(ctx) + data = filtering_core.set_filter_url(client, url=url, name=name, enabled=False, + whitelist=whitelist) + output(data or {"disabled": True, "url": url}, ctx.obj["as_json"]) + + +@filter_.command("refresh") +@click.option("--whitelist", is_flag=True, default=False) +@click.pass_context +def filter_refresh(ctx: click.Context, whitelist: bool): + """Trigger manual update of all filters.""" + client = make_client(ctx) + data = filtering_core.refresh(client, whitelist=whitelist) + output(data or {"refreshed": True}, ctx.obj["as_json"]) + + +# --------------------------------------------------------------------------- +# blocking +# --------------------------------------------------------------------------- + +@cli.group() +@click.pass_context +def blocking(ctx: click.Context): + """Parental, safebrowsing, safesearch controls.""" + + +@blocking.group() +def parental(): + """Parental control.""" + + +@parental.command("status") +@click.pass_context +def parental_status(ctx: click.Context): + client = make_client(ctx) + output(blocking_core.parental_status(client), ctx.obj["as_json"]) + + +@parental.command("enable") +@click.pass_context +def parental_enable(ctx: click.Context): + client = make_client(ctx) + output(blocking_core.parental_enable(client) or {"enabled": True}, ctx.obj["as_json"]) + + +@parental.command("disable") +@click.pass_context +def parental_disable(ctx: click.Context): + client = make_client(ctx) + output(blocking_core.parental_disable(client) or {"disabled": True}, ctx.obj["as_json"]) + + +@blocking.group() +def safebrowsing(): + """Safe browsing control.""" + + +@safebrowsing.command("status") +@click.pass_context +def safebrowsing_status(ctx: click.Context): + client = make_client(ctx) + output(blocking_core.safebrowsing_status(client), ctx.obj["as_json"]) + + +@safebrowsing.command("enable") +@click.pass_context +def safebrowsing_enable(ctx: click.Context): + client = make_client(ctx) + output(blocking_core.safebrowsing_enable(client) or {"enabled": True}, ctx.obj["as_json"]) + + +@safebrowsing.command("disable") +@click.pass_context +def safebrowsing_disable(ctx: click.Context): + client = make_client(ctx) + output(blocking_core.safebrowsing_disable(client) or {"disabled": True}, ctx.obj["as_json"]) + + +@blocking.group() +def safesearch(): + """Safe search control.""" + + +@safesearch.command("status") +@click.pass_context +def safesearch_status(ctx: click.Context): + client = make_client(ctx) + output(blocking_core.safesearch_status(client), ctx.obj["as_json"]) + + +@safesearch.command("enable") +@click.pass_context +def safesearch_enable(ctx: click.Context): + client = make_client(ctx) + output(blocking_core.safesearch_enable(client) or {"enabled": True}, ctx.obj["as_json"]) + + +@safesearch.command("disable") +@click.pass_context +def safesearch_disable(ctx: click.Context): + client = make_client(ctx) + output(blocking_core.safesearch_disable(client) or {"disabled": True}, ctx.obj["as_json"]) + + +# --------------------------------------------------------------------------- +# blocked-services +# --------------------------------------------------------------------------- + +@cli.group("blocked-services") +@click.pass_context +def blocked_services(ctx: click.Context): + """Blocked service categories.""" + + +@blocked_services.command("list") +@click.pass_context +def blocked_services_list(ctx: click.Context): + client = make_client(ctx) + output(blocking_core.blocked_services_get(client), ctx.obj["as_json"]) + + +@blocked_services.command("set") +@click.argument("services", nargs=-1, required=True) +@click.pass_context +def blocked_services_set(ctx: click.Context, services: tuple): + client = make_client(ctx) + output(blocking_core.blocked_services_set(client, list(services)) or {"set": list(services)}, + ctx.obj["as_json"]) + + +# --------------------------------------------------------------------------- +# clients +# --------------------------------------------------------------------------- + +@cli.group("clients") +@click.pass_context +def clients_(ctx: click.Context): + """Known client management.""" + + + +@clients_.command("list") +@click.pass_context +def clients_list(ctx: click.Context): + client = make_client(ctx) + output(clients_core.list_clients(client), ctx.obj["as_json"]) + + +@clients_.command("add") +@click.option("--name", required=True) +@click.option("--ip", required=True, help="Client IP address") +@click.pass_context +def clients_add(ctx: click.Context, name: str, ip: str): + c = make_client(ctx) + output(clients_core.add_client(c, name=name, ids=[ip]) or {"added": True, "name": name}, + ctx.obj["as_json"]) + + +@clients_.command("remove") +@click.option("--name", required=True) +@click.pass_context +def clients_remove(ctx: click.Context, name: str): + c = make_client(ctx) + output(clients_core.delete_client(c, name=name) or {"removed": True, "name": name}, + ctx.obj["as_json"]) + + +@clients_.command("show") +@click.option("--name", required=True) +@click.pass_context +def clients_show(ctx: click.Context, name: str): + c = make_client(ctx) + data = clients_core.list_clients(c) + all_clients = data.get("clients", []) if isinstance(data, dict) else [] + found = next((cl for cl in all_clients if cl.get("name") == name), None) + output(found or {"error": f"Client '{name}' not found"}, ctx.obj["as_json"]) + + +# --------------------------------------------------------------------------- +# stats +# --------------------------------------------------------------------------- + +@cli.group("stats") +@click.pass_context +def stats_(ctx: click.Context): + """DNS query statistics.""" + + + +@stats_.command("show") +@click.pass_context +def stats_show(ctx: click.Context): + client = make_client(ctx) + output(stats_core.get_stats(client), ctx.obj["as_json"]) + + +@stats_.command("reset") +@click.pass_context +def stats_reset(ctx: click.Context): + client = make_client(ctx) + output(stats_core.reset_stats(client) or {"reset": True}, ctx.obj["as_json"]) + + +@stats_.command("config") +@click.option("--interval", type=int, default=None, help="Retention in days") +@click.pass_context +def stats_config(ctx: click.Context, interval): + client = make_client(ctx) + if interval is not None: + output(stats_core.set_stats_config(client, interval), ctx.obj["as_json"]) + else: + output(stats_core.get_stats_config(client), ctx.obj["as_json"]) + + +# --------------------------------------------------------------------------- +# log +# --------------------------------------------------------------------------- + +@cli.group("log") +@click.pass_context +def log_(ctx: click.Context): + """Query log management.""" + + + +@log_.command("show") +@click.option("--limit", default=50, type=int) +@click.option("--offset", default=0, type=int) +@click.pass_context +def log_show(ctx: click.Context, limit: int, offset: int): + client = make_client(ctx) + output(log_core.get_log(client, limit=limit, offset=offset), ctx.obj["as_json"]) + + +@log_.command("config") +@click.option("--enabled/--disabled", default=None) +@click.option("--interval", type=int, default=None) +@click.pass_context +def log_config(ctx: click.Context, enabled, interval): + client = make_client(ctx) + if enabled is not None or interval is not None: + current = log_core.get_log_config(client) + effective_enabled = enabled if enabled is not None else current.get("enabled", True) + effective_interval = interval if interval is not None else current.get("interval", 90) + output(log_core.set_log_config(client, enabled=effective_enabled, + interval=effective_interval), ctx.obj["as_json"]) + else: + output(log_core.get_log_config(client), ctx.obj["as_json"]) + + +@log_.command("clear") +@click.pass_context +def log_clear(ctx: click.Context): + client = make_client(ctx) + output(log_core.clear_log(client) or {"cleared": True}, ctx.obj["as_json"]) + + +# --------------------------------------------------------------------------- +# rewrite +# --------------------------------------------------------------------------- + +@cli.group("rewrite") +@click.pass_context +def rewrite_(ctx: click.Context): + """DNS rewrite rules.""" + + + +@rewrite_.command("list") +@click.pass_context +def rewrite_list(ctx: click.Context): + client = make_client(ctx) + output(rewrite_core.list_rewrites(client), ctx.obj["as_json"]) + + +@rewrite_.command("add") +@click.option("--domain", required=True) +@click.option("--answer", required=True) +@click.pass_context +def rewrite_add(ctx: click.Context, domain: str, answer: str): + client = make_client(ctx) + output(rewrite_core.add_rewrite(client, domain=domain, answer=answer) or + {"added": True, "domain": domain, "answer": answer}, ctx.obj["as_json"]) + + +@rewrite_.command("remove") +@click.option("--domain", required=True) +@click.option("--answer", required=True) +@click.pass_context +def rewrite_remove(ctx: click.Context, domain: str, answer: str): + client = make_client(ctx) + output(rewrite_core.delete_rewrite(client, domain=domain, answer=answer) or + {"removed": True, "domain": domain}, ctx.obj["as_json"]) + + +# --------------------------------------------------------------------------- +# dhcp +# --------------------------------------------------------------------------- + +@cli.group("dhcp") +@click.pass_context +def dhcp_(ctx: click.Context): + """DHCP server management.""" + + + +@dhcp_.command("status") +@click.pass_context +def dhcp_status(ctx: click.Context): + client = make_client(ctx) + output(dhcp_core.get_status(client), ctx.obj["as_json"]) + + +@dhcp_.command("leases") +@click.pass_context +def dhcp_leases(ctx: click.Context): + client = make_client(ctx) + output(dhcp_core.get_leases(client), ctx.obj["as_json"]) + + +@dhcp_.command("add-static") +@click.option("--mac", required=True) +@click.option("--ip", required=True) +@click.option("--hostname", default="") +@click.pass_context +def dhcp_add_static(ctx: click.Context, mac: str, ip: str, hostname: str): + client = make_client(ctx) + output(dhcp_core.add_static_lease(client, mac=mac, ip=ip, hostname=hostname) or + {"added": True, "mac": mac, "ip": ip}, ctx.obj["as_json"]) + + +@dhcp_.command("remove-static") +@click.option("--mac", required=True) +@click.option("--ip", required=True) +@click.option("--hostname", default="") +@click.pass_context +def dhcp_remove_static(ctx: click.Context, mac: str, ip: str, hostname: str): + client = make_client(ctx) + output(dhcp_core.remove_static_lease(client, mac=mac, ip=ip, hostname=hostname) or + {"removed": True, "mac": mac}, ctx.obj["as_json"]) + + +# --------------------------------------------------------------------------- +# tls +# --------------------------------------------------------------------------- + +@cli.group("tls") +@click.pass_context +def tls_(ctx: click.Context): + """TLS/HTTPS configuration.""" + + + +@tls_.command("status") +@click.pass_context +def tls_status(ctx: click.Context): + client = make_client(ctx) + output(server_core.get_tls_status(client), ctx.obj["as_json"]) diff --git a/adguardhome/agent-harness/cli_anything/adguardhome/core/__init__.py b/adguardhome/agent-harness/cli_anything/adguardhome/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/adguardhome/agent-harness/cli_anything/adguardhome/core/blocking.py b/adguardhome/agent-harness/cli_anything/adguardhome/core/blocking.py new file mode 100644 index 0000000..868ce74 --- /dev/null +++ b/adguardhome/agent-harness/cli_anything/adguardhome/core/blocking.py @@ -0,0 +1,47 @@ +"""Blocking controls: parental, safebrowsing, safesearch, blocked services.""" + +from cli_anything.adguardhome.utils.adguardhome_backend import AdGuardHomeClient + + +def parental_status(client: AdGuardHomeClient) -> dict: + return client.get("/parental/status") + + +def parental_enable(client: AdGuardHomeClient) -> dict: + return client.post("/parental/enable") + + +def parental_disable(client: AdGuardHomeClient) -> dict: + return client.post("/parental/disable") + + +def safebrowsing_status(client: AdGuardHomeClient) -> dict: + return client.get("/safebrowsing/status") + + +def safebrowsing_enable(client: AdGuardHomeClient) -> dict: + return client.post("/safebrowsing/enable") + + +def safebrowsing_disable(client: AdGuardHomeClient) -> dict: + return client.post("/safebrowsing/disable") + + +def safesearch_status(client: AdGuardHomeClient) -> dict: + return client.get("/safesearch/status") + + +def safesearch_enable(client: AdGuardHomeClient) -> dict: + return client.post("/safesearch/enable") + + +def safesearch_disable(client: AdGuardHomeClient) -> dict: + return client.post("/safesearch/disable") + + +def blocked_services_get(client: AdGuardHomeClient) -> dict: + return client.get("/blocked_services/get") + + +def blocked_services_set(client: AdGuardHomeClient, services: list[str]) -> dict: + return client.post("/blocked_services/set", {"ids": services}) diff --git a/adguardhome/agent-harness/cli_anything/adguardhome/core/clients.py b/adguardhome/agent-harness/cli_anything/adguardhome/core/clients.py new file mode 100644 index 0000000..2998237 --- /dev/null +++ b/adguardhome/agent-harness/cli_anything/adguardhome/core/clients.py @@ -0,0 +1,30 @@ +"""Client management for AdGuardHome.""" + +from cli_anything.adguardhome.utils.adguardhome_backend import AdGuardHomeClient + + +def list_clients(client: AdGuardHomeClient) -> dict: + return client.get("/clients") + + +def add_client(client: AdGuardHomeClient, name: str, ids: list[str], + use_global_settings: bool = True, + filtering_enabled: bool = True) -> dict: + return client.post("/clients/add", { + "name": name, + "ids": ids, + "use_global_settings": use_global_settings, + "filtering_enabled": filtering_enabled, + "parental_enabled": False, + "safebrowsing_enabled": False, + "safesearch_enabled": False, + "use_global_blocked_services": True, + }) + + +def delete_client(client: AdGuardHomeClient, name: str) -> dict: + return client.post("/clients/delete", {"name": name}) + + +def update_client(client: AdGuardHomeClient, name: str, data: dict) -> dict: + return client.post("/clients/update", {"name": name, "data": data}) diff --git a/adguardhome/agent-harness/cli_anything/adguardhome/core/dhcp.py b/adguardhome/agent-harness/cli_anything/adguardhome/core/dhcp.py new file mode 100644 index 0000000..460b9b0 --- /dev/null +++ b/adguardhome/agent-harness/cli_anything/adguardhome/core/dhcp.py @@ -0,0 +1,25 @@ +"""DHCP server management for AdGuardHome.""" + +from cli_anything.adguardhome.utils.adguardhome_backend import AdGuardHomeClient + + +def get_status(client: AdGuardHomeClient) -> dict: + return client.get("/dhcp/status") + + +def get_leases(client: AdGuardHomeClient) -> dict: + return client.get("/dhcp/leases") + + +def add_static_lease(client: AdGuardHomeClient, mac: str, ip: str, + hostname: str) -> dict: + return client.post("/dhcp/add_static_lease", { + "mac": mac, "ip": ip, "hostname": hostname, + }) + + +def remove_static_lease(client: AdGuardHomeClient, mac: str, ip: str, + hostname: str) -> dict: + return client.post("/dhcp/remove_static_lease", { + "mac": mac, "ip": ip, "hostname": hostname, + }) diff --git a/adguardhome/agent-harness/cli_anything/adguardhome/core/filtering.py b/adguardhome/agent-harness/cli_anything/adguardhome/core/filtering.py new file mode 100644 index 0000000..6daceef --- /dev/null +++ b/adguardhome/agent-harness/cli_anything/adguardhome/core/filtering.py @@ -0,0 +1,39 @@ +"""Filtering rules management for AdGuardHome.""" + +from cli_anything.adguardhome.utils.adguardhome_backend import AdGuardHomeClient + + +def get_status(client: AdGuardHomeClient) -> dict: + return client.get("/filtering/status") + + +def set_enabled(client: AdGuardHomeClient, enabled: bool) -> dict: + current = get_status(client) + interval = current.get("interval", 24) + return client.post("/filtering/config", {"enabled": enabled, "interval": interval}) + + +def add_filter(client: AdGuardHomeClient, url: str, name: str, + whitelist: bool = False) -> dict: + return client.post("/filtering/add_url", { + "name": name, + "url": url, + "whitelist": whitelist, + }) + + +def remove_filter(client: AdGuardHomeClient, url: str, whitelist: bool = False) -> dict: + return client.post("/filtering/remove_url", {"url": url, "whitelist": whitelist}) + + +def set_filter_url(client: AdGuardHomeClient, url: str, name: str, + enabled: bool, whitelist: bool = False) -> dict: + return client.post("/filtering/set_url", { + "url": url, + "data": {"name": name, "url": url, "enabled": enabled}, + "whitelist": whitelist, + }) + + +def refresh(client: AdGuardHomeClient, whitelist: bool = False) -> dict: + return client.post("/filtering/refresh", {"whitelist": whitelist}) diff --git a/adguardhome/agent-harness/cli_anything/adguardhome/core/log.py b/adguardhome/agent-harness/cli_anything/adguardhome/core/log.py new file mode 100644 index 0000000..d19eba0 --- /dev/null +++ b/adguardhome/agent-harness/cli_anything/adguardhome/core/log.py @@ -0,0 +1,24 @@ +"""Query log for AdGuardHome.""" + +from cli_anything.adguardhome.utils.adguardhome_backend import AdGuardHomeClient + + +def get_log(client: AdGuardHomeClient, limit: int = 100, offset: int = 0) -> dict: + return client.get("/querylog", params={"limit": limit, "offset": offset}) + + +def get_log_config(client: AdGuardHomeClient) -> dict: + return client.get("/querylog_config") + + +def set_log_config(client: AdGuardHomeClient, enabled: bool, + interval: int = 90) -> dict: + return client.post("/querylog_config", { + "enabled": enabled, + "interval": interval, + "anonymize_client_ip": False, + }) + + +def clear_log(client: AdGuardHomeClient) -> dict: + return client.post("/querylog_clear") diff --git a/adguardhome/agent-harness/cli_anything/adguardhome/core/project.py b/adguardhome/agent-harness/cli_anything/adguardhome/core/project.py new file mode 100644 index 0000000..f7fc9b6 --- /dev/null +++ b/adguardhome/agent-harness/cli_anything/adguardhome/core/project.py @@ -0,0 +1,52 @@ +"""Connection configuration management for cli-anything-adguardhome.""" + +import json +import os +from pathlib import Path + +DEFAULT_CONFIG_PATH = Path.home() / ".config" / "cli-anything-adguardhome.json" +DEFAULT_HOST = "localhost" +DEFAULT_PORT = 3000 + + +def load_config(config_path: Path | None = None) -> dict: + """Load connection config from file, with env var and default fallbacks.""" + path = config_path or DEFAULT_CONFIG_PATH + config: dict = { + "host": DEFAULT_HOST, + "port": DEFAULT_PORT, + "username": "", + "password": "", + "https": False, + } + if path.exists(): + try: + with open(path) as f: + file_config = json.load(f) + for key in ("host", "port", "username", "password", "https"): + if key in file_config: + config[key] = file_config[key] + except (json.JSONDecodeError, OSError): + pass + if os.getenv("AGH_HOST"): + config["host"] = os.environ["AGH_HOST"] + if os.getenv("AGH_PORT"): + config["port"] = int(os.environ["AGH_PORT"]) + if os.getenv("AGH_USERNAME"): + config["username"] = os.environ["AGH_USERNAME"] + if os.getenv("AGH_PASSWORD"): + config["password"] = os.environ["AGH_PASSWORD"] + return config + + +def save_config(host: str, port: int, username: str, password: str, + https: bool = False, + config_path: Path | None = None) -> Path: + """Save connection settings to config file.""" + path = config_path or DEFAULT_CONFIG_PATH + path.parent.mkdir(parents=True, exist_ok=True) + data = {"host": host, "port": port, "username": username, "password": password, + "https": https} + with open(path, "w") as f: + json.dump(data, f, indent=2) + return path diff --git a/adguardhome/agent-harness/cli_anything/adguardhome/core/rewrite.py b/adguardhome/agent-harness/cli_anything/adguardhome/core/rewrite.py new file mode 100644 index 0000000..cfba355 --- /dev/null +++ b/adguardhome/agent-harness/cli_anything/adguardhome/core/rewrite.py @@ -0,0 +1,15 @@ +"""DNS rewrite rules for AdGuardHome.""" + +from cli_anything.adguardhome.utils.adguardhome_backend import AdGuardHomeClient + + +def list_rewrites(client: AdGuardHomeClient) -> list: + return client.get("/rewrite/list") + + +def add_rewrite(client: AdGuardHomeClient, domain: str, answer: str) -> dict: + return client.post("/rewrite/add", {"domain": domain, "answer": answer}) + + +def delete_rewrite(client: AdGuardHomeClient, domain: str, answer: str) -> dict: + return client.post("/rewrite/delete", {"domain": domain, "answer": answer}) diff --git a/adguardhome/agent-harness/cli_anything/adguardhome/core/server.py b/adguardhome/agent-harness/cli_anything/adguardhome/core/server.py new file mode 100644 index 0000000..8e26dc9 --- /dev/null +++ b/adguardhome/agent-harness/cli_anything/adguardhome/core/server.py @@ -0,0 +1,19 @@ +"""Server/global management for AdGuardHome.""" + +from cli_anything.adguardhome.utils.adguardhome_backend import AdGuardHomeClient + + +def get_status(client: AdGuardHomeClient) -> dict: + return client.get("/status") + + +def get_version(client: AdGuardHomeClient) -> dict: + return client.get("/version") + + +def restart(client: AdGuardHomeClient) -> dict: + return client.post("/restart") + + +def get_tls_status(client: AdGuardHomeClient) -> dict: + return client.get("/tls/status") diff --git a/adguardhome/agent-harness/cli_anything/adguardhome/core/session.py b/adguardhome/agent-harness/cli_anything/adguardhome/core/session.py new file mode 100644 index 0000000..08d8903 --- /dev/null +++ b/adguardhome/agent-harness/cli_anything/adguardhome/core/session.py @@ -0,0 +1,25 @@ +"""Session state management for cli-anything-adguardhome.""" + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class Session: + """In-memory session state for the REPL.""" + host: str = "localhost" + port: int = 3000 + username: str = "" + password: str = "" + history: list[str] = field(default_factory=list) + + def add_history(self, command: str) -> None: + self.history.append(command) + + def to_dict(self) -> dict[str, Any]: + return { + "host": self.host, + "port": self.port, + "username": self.username, + "connected": True, + } diff --git a/adguardhome/agent-harness/cli_anything/adguardhome/core/stats.py b/adguardhome/agent-harness/cli_anything/adguardhome/core/stats.py new file mode 100644 index 0000000..d2362fc --- /dev/null +++ b/adguardhome/agent-harness/cli_anything/adguardhome/core/stats.py @@ -0,0 +1,19 @@ +"""Statistics for AdGuardHome.""" + +from cli_anything.adguardhome.utils.adguardhome_backend import AdGuardHomeClient + + +def get_stats(client: AdGuardHomeClient) -> dict: + return client.get("/stats") + + +def reset_stats(client: AdGuardHomeClient) -> dict: + return client.post("/stats_reset") + + +def get_stats_config(client: AdGuardHomeClient) -> dict: + return client.get("/stats_config") + + +def set_stats_config(client: AdGuardHomeClient, interval: int) -> dict: + return client.post("/stats_config", {"interval": interval}) diff --git a/adguardhome/agent-harness/cli_anything/adguardhome/skills/SKILL.md b/adguardhome/agent-harness/cli_anything/adguardhome/skills/SKILL.md new file mode 100644 index 0000000..13a9cac --- /dev/null +++ b/adguardhome/agent-harness/cli_anything/adguardhome/skills/SKILL.md @@ -0,0 +1,256 @@ +--- +name: >- + cli-anything-adguardhome +description: >- + Command-line interface for AdGuard Home - Network-wide ad blocking and DNS management via AdGuard Home REST API. Designed for AI agents and power users who need to manage filtering, DNS rewrites, clients, DHCP, and query logs without a GUI. +--- + +# cli-anything-adguardhome + +Network-wide ad blocking and DNS management via the AdGuard Home REST API. Designed for AI agents and power users who need to manage filtering, DNS rewrites, clients, DHCP, and query logs without a GUI. + +## Installation + +This CLI is installed as part of the cli-anything-adguardhome package: + +```bash +pip install cli-anything-adguardhome +``` + +**Prerequisites:** +- Python 3.10+ +- AdGuard Home must be installed and running +- Install AdGuard Home: `curl -s -S -L https://raw.githubusercontent.com/AdguardTeam/AdGuardHome/master/scripts/install.sh | sh -s -- -v` + +## Usage + +### Basic Commands + +```bash +# Show help +cli-anything-adguardhome --help + +# Start interactive REPL mode +cli-anything-adguardhome + +# Check server status +cli-anything-adguardhome server status + +# Run with JSON output (for agent consumption) +cli-anything-adguardhome --json server status +``` + +### REPL Mode + +When invoked without a subcommand, the CLI enters an interactive REPL session: + +```bash +cli-anything-adguardhome +# Enter commands interactively with tab-completion and history +``` + +## Command Groups + +### Config + +Connection and configuration management. + +| Command | Description | +|---------|-------------| +| `show` | Show current connection configuration | +| `save` | Save connection settings to a config file | +| `test` | Test the connection to AdGuard Home | + +### Server + +Server status and control commands. + +| Command | Description | +|---------|-------------| +| `status` | Show server protection status | +| `version` | Show AdGuard Home version | +| `restart` | Restart the AdGuard Home server | + +### Filter + +DNS filter list management. + +| Command | Description | +|---------|-------------| +| `list` | List all configured filter lists | +| `status` | Show filtering status | +| `toggle` | Enable or disable filtering globally | +| `add` | Add a new filter list by URL | +| `remove` | Remove a filter list | +| `enable` | Enable a specific filter list | +| `disable` | Disable a specific filter list | +| `refresh` | Force-refresh all filter lists | + +### Blocking + +Parental control, safe browsing, and safe search settings. + +| Command | Description | +|---------|-------------| +| `parental status` | Show parental control status | +| `parental enable` | Enable parental control | +| `parental disable` | Disable parental control | +| `safebrowsing status` | Show safe browsing status | +| `safebrowsing enable` | Enable safe browsing | +| `safebrowsing disable` | Disable safe browsing | +| `safesearch status` | Show safe search status | +| `safesearch enable` | Enable safe search | +| `safesearch disable` | Disable safe search | + +### Blocked-Services + +Manage blocked internet services. + +| Command | Description | +|---------|-------------| +| `list` | List currently blocked services | +| `set` | Set the list of blocked services | + +### Clients + +Client device management. + +| Command | Description | +|---------|-------------| +| `list` | List all configured clients | +| `add` | Add a new client by name and IP | +| `remove` | Remove a client | +| `show` | Show details for a specific client | + +### Stats + +Query statistics. + +| Command | Description | +|---------|-------------| +| `show` | Show DNS query statistics | +| `reset` | Reset all statistics | +| `config` | View or update statistics retention interval | + +### Log + +DNS query log management. + +| Command | Description | +|---------|-------------| +| `show` | Show recent DNS query log entries | +| `config` | View or update query log settings | +| `clear` | Clear the query log | + +### Rewrite + +DNS rewrite rules. + +| Command | Description | +|---------|-------------| +| `list` | List all DNS rewrite rules | +| `add` | Add a DNS rewrite rule | +| `remove` | Remove a DNS rewrite rule | + +### DHCP + +DHCP server management. + +| Command | Description | +|---------|-------------| +| `status` | Show DHCP server status | +| `leases` | List active DHCP leases | +| `add-static` | Add a static DHCP lease | +| `remove-static` | Remove a static DHCP lease | + +### TLS + +TLS/HTTPS configuration. + +| Command | Description | +|---------|-------------| +| `status` | Show TLS configuration status | + +## Examples + +### Check Server Status + +```bash +cli-anything-adguardhome server status +cli-anything-adguardhome server version +``` + +### Manage Filter Lists + +```bash +# List current filters +cli-anything-adguardhome filter list + +# Add a new blocklist +cli-anything-adguardhome filter add --url https://somehost.com/list.txt --name "My List" + +# Refresh all filters +cli-anything-adguardhome filter refresh +``` + +### DNS Rewrites + +```bash +# Add a local DNS entry +cli-anything-adguardhome rewrite add --domain "myserver.local" --answer "192.168.1.50" + +# List all rewrites +cli-anything-adguardhome rewrite list +``` + +### Client Management + +```bash +cli-anything-adguardhome clients add --name "My PC" --ip 192.168.1.100 +cli-anything-adguardhome clients list +``` + +### Query Statistics + +```bash +# Show stats (human-readable) +cli-anything-adguardhome stats show + +# Show stats (JSON for agents) +cli-anything-adguardhome --json stats show +``` + +## Output Formats + +All commands support dual output modes: + +- **Human-readable** (default): Tables, colors, formatted text +- **Machine-readable** (`--json` flag): Structured JSON for agent consumption + +```bash +# Human output +cli-anything-adguardhome filter list + +# JSON output for agents +cli-anything-adguardhome --json filter list +``` + +## For AI Agents + +When using this CLI programmatically: + +1. **Always use `--json` flag** for parseable output +2. **Check return codes** - 0 for success, non-zero for errors +3. **Parse stderr** for error messages on failure +4. **Use absolute paths** for all file operations +5. **Test connection first** with `config test` before other commands + +## More Information + +- Full documentation: See README.md in the package +- Test coverage: See TEST.md in the package +- Methodology: See HARNESS.md in the cli-anything-plugin + +## Version + +1.0.0 diff --git a/adguardhome/agent-harness/cli_anything/adguardhome/tests/TEST.md b/adguardhome/agent-harness/cli_anything/adguardhome/tests/TEST.md new file mode 100644 index 0000000..e69a1d7 --- /dev/null +++ b/adguardhome/agent-harness/cli_anything/adguardhome/tests/TEST.md @@ -0,0 +1,121 @@ +# Test Plan - cli-anything-adguardhome + +## Test Inventory Plan + +- `test_core.py`: 20 unit tests (no real AdGuardHome needed) +- `test_full_e2e.py`: 12 E2E + subprocess tests (Docker AdGuardHome on port 3001) + +## Unit Test Plan (test_core.py) + +### AdGuardHomeClient (utils/adguardhome_backend.py) +- `test_client_init_default` - default host/port, no auth +- `test_client_init_with_auth` - auth set on session +- `test_client_url_construction` - base URL built correctly +- `test_get_success` - GET returns deserialized JSON +- `test_get_empty_response` - GET returns {} on empty body +- `test_post_json` - POST sends JSON body +- `test_post_empty` - POST with no data +- `test_connection_error_raises_runtime` - ConnectionError raises RuntimeError with instructions + +### project.py +- `test_load_config_defaults` - returns localhost:3000 when no file/env +- `test_load_config_from_file` - loads from JSON file +- `test_load_config_env_override` - env vars override file +- `test_save_config` - writes JSON file correctly + +### filtering.py +- `test_get_status` - calls GET /filtering/status +- `test_add_filter` - calls POST /filtering/add_url with correct body +- `test_remove_filter` - calls POST /filtering/remove_url +- `test_set_enabled` - calls POST /filtering/config + +### blocking.py +- `test_parental_status` - calls GET /parental/status +- `test_parental_enable` - calls POST /parental/enable +- `test_safebrowsing_status` - calls GET /safebrowsing/status + +### clients.py +- `test_list_clients` - calls GET /clients +- `test_add_client` - calls POST /clients/add with correct body + +### rewrite.py +- `test_list_rewrites` - calls GET /rewrite/list +- `test_add_rewrite` - calls POST /rewrite/add + +## E2E Test Plan (test_full_e2e.py) + +### Setup +- Docker fixture starts `adguard/adguardhome` on port 3001 with pre-configured YAML +- Teardown removes the container + +### Workflow: CLI subprocess tests (no real AdGuardHome) +- `test_help` - `cli-anything-adguardhome --help` exits 0 +- `test_config_show_json` - `--json config show` returns valid JSON with host/port +- `test_server_version_json` - `--json server version` returns JSON (requires running instance) +- `test_filter_list_json` - `--json filter list` returns JSON + +### Workflow: Full filter lifecycle (requires Docker AdGuardHome) +- `test_filter_list` - list filters on fresh instance +- `test_rewrite_add_and_list` - add rewrite, verify in list +- `test_rewrite_remove` - remove rewrite, verify gone + +--- + +## Test Results + +(appended after pytest run) + +--- + +## Test Results + +``` +============================= test session starts ============================== +platform linux -- Python 3.13.5, pytest-9.0.2, pluggy-1.6.0 +rootdir: /home/yoan/work/AdGuardHome/agent-harness + +cli_anything/adguardhome/tests/test_core.py::TestAdGuardHomeClient::test_client_init_default PASSED +cli_anything/adguardhome/tests/test_core.py::TestAdGuardHomeClient::test_client_init_with_auth PASSED +cli_anything/adguardhome/tests/test_core.py::TestAdGuardHomeClient::test_client_init_no_auth PASSED +cli_anything/adguardhome/tests/test_core.py::TestAdGuardHomeClient::test_client_url_construction PASSED +cli_anything/adguardhome/tests/test_core.py::TestAdGuardHomeClient::test_get_success PASSED +cli_anything/adguardhome/tests/test_core.py::TestAdGuardHomeClient::test_get_empty_response PASSED +cli_anything/adguardhome/tests/test_core.py::TestAdGuardHomeClient::test_post_json PASSED +cli_anything/adguardhome/tests/test_core.py::TestAdGuardHomeClient::test_post_empty PASSED +cli_anything/adguardhome/tests/test_core.py::TestAdGuardHomeClient::test_connection_error_raises_runtime PASSED +cli_anything/adguardhome/tests/test_core.py::TestProject::test_load_config_defaults PASSED +cli_anything/adguardhome/tests/test_core.py::TestProject::test_load_config_from_file PASSED +cli_anything/adguardhome/tests/test_core.py::TestProject::test_load_config_env_override PASSED +cli_anything/adguardhome/tests/test_core.py::TestProject::test_save_config PASSED +cli_anything/adguardhome/tests/test_core.py::TestFiltering::test_get_status PASSED +cli_anything/adguardhome/tests/test_core.py::TestFiltering::test_add_filter PASSED +cli_anything/adguardhome/tests/test_core.py::TestFiltering::test_remove_filter PASSED +cli_anything/adguardhome/tests/test_core.py::TestFiltering::test_set_enabled PASSED +cli_anything/adguardhome/tests/test_core.py::TestBlocking::test_parental_status PASSED +cli_anything/adguardhome/tests/test_core.py::TestBlocking::test_parental_enable PASSED +cli_anything/adguardhome/tests/test_core.py::TestBlocking::test_safebrowsing_status PASSED +cli_anything/adguardhome/tests/test_core.py::TestClients::test_list_clients PASSED +cli_anything/adguardhome/tests/test_core.py::TestClients::test_add_client PASSED +cli_anything/adguardhome/tests/test_core.py::TestRewrite::test_list_rewrites PASSED +cli_anything/adguardhome/tests/test_core.py::TestRewrite::test_add_rewrite PASSED +cli_anything/adguardhome/tests/test_full_e2e.py::TestCLISubprocess::test_help PASSED +cli_anything/adguardhome/tests/test_full_e2e.py::TestCLISubprocess::test_config_show_json PASSED +cli_anything/adguardhome/tests/test_full_e2e.py::TestCLISubprocess::test_config_show_default_host PASSED +cli_anything/adguardhome/tests/test_full_e2e.py::TestCLISubprocess::test_help_subcommands_listed PASSED +cli_anything/adguardhome/tests/test_full_e2e.py::TestCLISubprocess::test_filter_help PASSED +cli_anything/adguardhome/tests/test_full_e2e.py::TestCLISubprocess::test_rewrite_help PASSED +cli_anything/adguardhome/tests/test_full_e2e.py::TestCLISubprocess::test_blocking_help PASSED +cli_anything/adguardhome/tests/test_full_e2e.py::TestDockerE2E::test_server_status_json PASSED +cli_anything/adguardhome/tests/test_full_e2e.py::TestDockerE2E::test_filter_list_json PASSED +cli_anything/adguardhome/tests/test_full_e2e.py::TestDockerE2E::test_rewrite_lifecycle PASSED +cli_anything/adguardhome/tests/test_full_e2e.py::TestDockerE2E::test_stats_show_json PASSED +cli_anything/adguardhome/tests/test_full_e2e.py::TestDockerE2E::test_config_test PASSED + +============================== 36 passed in 6.57s ============================== +``` + +**36/36 passed (100%) — 2026-03-13** + +- Unit tests: 24/24 +- Subprocess tests (installed CLI): 7/7 +- Docker E2E tests (real AdGuardHome v0.107.73): 5/5 diff --git a/adguardhome/agent-harness/cli_anything/adguardhome/tests/__init__.py b/adguardhome/agent-harness/cli_anything/adguardhome/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/adguardhome/agent-harness/cli_anything/adguardhome/tests/test_core.py b/adguardhome/agent-harness/cli_anything/adguardhome/tests/test_core.py new file mode 100644 index 0000000..1855483 --- /dev/null +++ b/adguardhome/agent-harness/cli_anything/adguardhome/tests/test_core.py @@ -0,0 +1,259 @@ +"""Unit tests for cli-anything-adguardhome core modules. + +No real AdGuardHome instance needed - all HTTP calls are mocked. +""" + +import json +import os +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from cli_anything.adguardhome.utils.adguardhome_backend import AdGuardHomeClient +from cli_anything.adguardhome.core import project, filtering, blocking, clients, rewrite + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def mock_response(data=None, status=200, text=""): + resp = MagicMock(spec=requests.Response) + resp.status_code = status + if data is not None: + resp.json.return_value = data + resp.content = json.dumps(data).encode() + resp.text = json.dumps(data) + else: + resp.json.side_effect = ValueError("no json") + resp.content = text.encode() if text else b"" + resp.text = text + resp.raise_for_status = MagicMock() + return resp + + +def make_client(host="localhost", port=3000, username="admin", password="secret"): + return AdGuardHomeClient(host=host, port=port, username=username, password=password) + + +# --------------------------------------------------------------------------- +# AdGuardHomeClient +# --------------------------------------------------------------------------- + +class TestAdGuardHomeClient: + def test_client_init_default(self): + c = AdGuardHomeClient() + assert c.base_url == "http://localhost:3000/control" + assert c.host == "localhost" + assert c.port == 3000 + + def test_client_init_with_auth(self): + c = AdGuardHomeClient(username="admin", password="pass") + assert c.session.auth == ("admin", "pass") + + def test_client_init_no_auth(self): + c = AdGuardHomeClient() + assert c.session.auth is None + + def test_client_url_construction(self): + c = AdGuardHomeClient(host="192.168.1.1", port=8080) + assert c._url("/status") == "http://192.168.1.1:8080/control/status" + assert c._url("status") == "http://192.168.1.1:8080/control/status" + + def test_get_success(self): + c = make_client() + resp = mock_response({"running": True}) + with patch.object(c.session, "get", return_value=resp) as mock_get: + result = c.get("/status") + assert result == {"running": True} + mock_get.assert_called_once() + + def test_get_empty_response(self): + c = make_client() + resp = mock_response() + with patch.object(c.session, "get", return_value=resp): + result = c.get("/restart") + assert result == {} + + def test_post_json(self): + c = make_client() + resp = mock_response({}) + with patch.object(c.session, "post", return_value=resp) as mock_post: + c.post("/filtering/add_url", {"url": "http://example.com/list.txt", "name": "Test"}) + call_kwargs = mock_post.call_args + assert call_kwargs.kwargs.get("json") == {"url": "http://example.com/list.txt", "name": "Test"} + + def test_post_empty(self): + c = make_client() + resp = mock_response() + with patch.object(c.session, "post", return_value=resp) as mock_post: + result = c.post("/restart") + assert result == {} + mock_post.assert_called_once() + + def test_connection_error_raises_runtime(self): + c = make_client() + with patch.object(c.session, "get", side_effect=requests.exceptions.ConnectionError("refused")): + with pytest.raises(RuntimeError) as exc_info: + c.get("/status") + assert "Cannot connect to AdGuardHome" in str(exc_info.value) + assert "docker run" in str(exc_info.value).lower() or "docker" in str(exc_info.value).lower() + + +# --------------------------------------------------------------------------- +# project.py +# --------------------------------------------------------------------------- + +class TestProject: + def test_load_config_defaults(self, tmp_path): + result = project.load_config(config_path=tmp_path / "nonexistent.json") + assert result["host"] == "localhost" + assert result["port"] == 3000 + assert result["username"] == "" + assert result["password"] == "" + + def test_load_config_from_file(self, tmp_path): + cfg_file = tmp_path / "config.json" + cfg_file.write_text(json.dumps({ + "host": "192.168.1.1", "port": 8080, + "username": "admin", "password": "secret" + })) + result = project.load_config(config_path=cfg_file) + assert result["host"] == "192.168.1.1" + assert result["port"] == 8080 + assert result["username"] == "admin" + + def test_load_config_env_override(self, tmp_path, monkeypatch): + cfg_file = tmp_path / "config.json" + cfg_file.write_text(json.dumps({"host": "from-file", "port": 3000})) + monkeypatch.setenv("AGH_HOST", "from-env") + monkeypatch.setenv("AGH_PORT", "9000") + result = project.load_config(config_path=cfg_file) + assert result["host"] == "from-env" + assert result["port"] == 9000 + + def test_save_config(self, tmp_path): + path = tmp_path / "config.json" + saved = project.save_config("myhost", 4000, "user", "pass", config_path=path) + assert saved == path + data = json.loads(path.read_text()) + assert data["host"] == "myhost" + assert data["port"] == 4000 + + +# --------------------------------------------------------------------------- +# filtering.py +# --------------------------------------------------------------------------- + +class TestFiltering: + def test_get_status(self): + c = make_client() + resp = mock_response({"enabled": True, "filters": []}) + with patch.object(c.session, "get", return_value=resp): + result = filtering.get_status(c) + assert result["enabled"] is True + + def test_add_filter(self): + c = make_client() + resp = mock_response({}) + with patch.object(c.session, "post", return_value=resp) as mock_post: + filtering.add_filter(c, url="http://example.com/list.txt", name="Test") + body = mock_post.call_args.kwargs["json"] + assert body["url"] == "http://example.com/list.txt" + assert body["name"] == "Test" + assert body["whitelist"] is False + + def test_remove_filter(self): + c = make_client() + resp = mock_response({}) + with patch.object(c.session, "post", return_value=resp) as mock_post: + filtering.remove_filter(c, url="http://example.com/list.txt") + body = mock_post.call_args.kwargs["json"] + assert body["url"] == "http://example.com/list.txt" + + def test_set_enabled(self): + c = make_client() + status_resp = mock_response({"enabled": False, "interval": 48}) + post_resp = mock_response({}) + with patch.object(c.session, "get", return_value=status_resp): + with patch.object(c.session, "post", return_value=post_resp) as mock_post: + filtering.set_enabled(c, enabled=True) + body = mock_post.call_args.kwargs["json"] + assert body["enabled"] is True + assert body["interval"] == 48 + + +# --------------------------------------------------------------------------- +# blocking.py +# --------------------------------------------------------------------------- + +class TestBlocking: + def test_parental_status(self): + c = make_client() + resp = mock_response({"enabled": False}) + with patch.object(c.session, "get", return_value=resp): + result = blocking.parental_status(c) + assert result == {"enabled": False} + + def test_parental_enable(self): + c = make_client() + resp = mock_response() + with patch.object(c.session, "post", return_value=resp) as mock_post: + blocking.parental_enable(c) + assert "/parental/enable" in mock_post.call_args.args[0] + + def test_safebrowsing_status(self): + c = make_client() + resp = mock_response({"enabled": True}) + with patch.object(c.session, "get", return_value=resp): + result = blocking.safebrowsing_status(c) + assert result["enabled"] is True + + +# --------------------------------------------------------------------------- +# clients.py +# --------------------------------------------------------------------------- + +class TestClients: + def test_list_clients(self): + c = make_client() + data = {"clients": [{"name": "PC", "ids": ["192.168.1.10"]}], "auto_clients": []} + resp = mock_response(data) + with patch.object(c.session, "get", return_value=resp): + result = clients.list_clients(c) + assert len(result["clients"]) == 1 + + def test_add_client(self): + c = make_client() + resp = mock_response({}) + with patch.object(c.session, "post", return_value=resp) as mock_post: + clients.add_client(c, name="MyPC", ids=["192.168.1.100"]) + body = mock_post.call_args.kwargs["json"] + assert body["name"] == "MyPC" + assert "192.168.1.100" in body["ids"] + + +# --------------------------------------------------------------------------- +# rewrite.py +# --------------------------------------------------------------------------- + +class TestRewrite: + def test_list_rewrites(self): + c = make_client() + data = [{"domain": "myserver.local", "answer": "192.168.1.50"}] + resp = mock_response(data) + with patch.object(c.session, "get", return_value=resp): + result = rewrite.list_rewrites(c) + assert len(result) == 1 + assert result[0]["domain"] == "myserver.local" + + def test_add_rewrite(self): + c = make_client() + resp = mock_response({}) + with patch.object(c.session, "post", return_value=resp) as mock_post: + rewrite.add_rewrite(c, domain="myserver.local", answer="192.168.1.50") + body = mock_post.call_args.kwargs["json"] + assert body["domain"] == "myserver.local" + assert body["answer"] == "192.168.1.50" diff --git a/adguardhome/agent-harness/cli_anything/adguardhome/tests/test_full_e2e.py b/adguardhome/agent-harness/cli_anything/adguardhome/tests/test_full_e2e.py new file mode 100644 index 0000000..d6be1f1 --- /dev/null +++ b/adguardhome/agent-harness/cli_anything/adguardhome/tests/test_full_e2e.py @@ -0,0 +1,273 @@ +"""E2E and subprocess tests for cli-anything-adguardhome. + +Subprocess tests work without AdGuardHome (test CLI mechanics). +Docker tests require: docker pull adguard/adguardhome +""" + +import json +import os +import shutil +import subprocess +import sys +import time +from pathlib import Path + +import pytest +import requests + + +# --------------------------------------------------------------------------- +# CLI resolver +# --------------------------------------------------------------------------- + +def _resolve_cli(name: str) -> list[str]: + """Resolve installed CLI command; falls back to python -m for dev. + + Set env CLI_ANYTHING_FORCE_INSTALLED=1 to require the installed command. + """ + force = os.environ.get("CLI_ANYTHING_FORCE_INSTALLED", "").strip() == "1" + path = shutil.which(name) + if path: + print(f"[_resolve_cli] Using installed command: {path}") + return [path] + if force: + raise RuntimeError( + f"{name} not found in PATH. Install with:\n" + f" cd agent-harness && pip install -e ." + ) + module = "cli_anything.adguardhome.adguardhome_cli" + print(f"[_resolve_cli] Falling back to: {sys.executable} -m {module}") + return [sys.executable, "-m", module] + + +# --------------------------------------------------------------------------- +# Docker fixture +# --------------------------------------------------------------------------- + +AGH_TEST_PORT = 3001 +AGH_TEST_HOST = "localhost" +AGH_CONTAINER = "agh-cli-test" + + +def _wait_for_adguardhome(port: int, timeout: int = 30) -> bool: + """Wait until AdGuardHome API responds.""" + deadline = time.time() + timeout + while time.time() < deadline: + try: + r = requests.get(f"http://localhost:{port}/control/status", timeout=2) + if r.status_code in (200, 401, 403): + return True + except requests.exceptions.ConnectionError: + pass + time.sleep(1) + return False + + +def _configure_adguardhome(port: int, username: str, password: str) -> bool: + """Run the setup wizard via the install API.""" + url = f"http://localhost:{port}/control/install/configure" + payload = { + "web": {"ip": "0.0.0.0", "port": 3000, "status": "", "can_autofix": False}, + "dns": {"ip": "0.0.0.0", "port": 53, "status": "", "can_autofix": False}, + "username": username, + "password": password, + } + try: + r = requests.post(url, json=payload, timeout=10) + return r.status_code == 200 + except Exception: + return False + + +@pytest.fixture(scope="module") +def agh_docker(): + """Start AdGuardHome in Docker for E2E tests, configure via install API.""" + username = "admin" + password = "admin123" + + # Stop any existing container + subprocess.run(["docker", "rm", "-f", AGH_CONTAINER], capture_output=True) + + # Start AdGuardHome container (no config mount - will use install API) + result = subprocess.run([ + "docker", "run", "-d", + "--name", AGH_CONTAINER, + "-p", f"{AGH_TEST_PORT}:3000", + "--cap-add=NET_ADMIN", + "adguard/adguardhome", + ], capture_output=True, text=True) + + if result.returncode != 0: + pytest.skip(f"Could not start AdGuardHome Docker: {result.stderr}") + + # Wait for setup wizard to be available + deadline = time.time() + 30 + setup_ready = False + while time.time() < deadline: + try: + r = requests.get(f"http://localhost:{AGH_TEST_PORT}/control/install/get_addresses", + timeout=2) + if r.status_code == 200: + setup_ready = True + break + except requests.exceptions.ConnectionError: + pass + time.sleep(1) + + if not setup_ready: + subprocess.run(["docker", "rm", "-f", AGH_CONTAINER], capture_output=True) + pytest.skip("AdGuardHome setup wizard not reachable in time") + + # Run setup wizard + if not _configure_adguardhome(AGH_TEST_PORT, username, password): + subprocess.run(["docker", "rm", "-f", AGH_CONTAINER], capture_output=True) + pytest.skip("Could not configure AdGuardHome via install API") + + # Wait for configured instance to be ready + if not _wait_for_adguardhome(AGH_TEST_PORT, timeout=20): + subprocess.run(["docker", "rm", "-f", AGH_CONTAINER], capture_output=True) + pytest.skip("AdGuardHome not ready after configuration") + + print(f"\n AdGuardHome running at localhost:{AGH_TEST_PORT} (admin/admin123)") + + yield {"host": AGH_TEST_HOST, "port": AGH_TEST_PORT, + "username": username, "password": password} + + subprocess.run(["docker", "rm", "-f", AGH_CONTAINER], capture_output=True) + + +# --------------------------------------------------------------------------- +# Subprocess tests (no AdGuardHome needed) +# --------------------------------------------------------------------------- + +class TestCLISubprocess: + CLI_BASE = _resolve_cli("cli-anything-adguardhome") + + def _run(self, args: list[str], check: bool = True, env: dict | None = None) -> subprocess.CompletedProcess: + run_env = os.environ.copy() + if env: + run_env.update(env) + return subprocess.run( + self.CLI_BASE + args, + capture_output=True, text=True, + check=check, + env=run_env, + ) + + def test_help(self): + result = self._run(["--help"]) + assert result.returncode == 0 + assert "adguardhome" in result.stdout.lower() or "Usage" in result.stdout + + def test_config_show_json(self): + result = self._run(["--json", "config", "show"]) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert "host" in data + assert "port" in data + + def test_config_show_default_host(self): + result = self._run(["--json", "config", "show"]) + data = json.loads(result.stdout) + assert data["host"] == "localhost" + assert data["port"] == 3000 + + def test_help_subcommands_listed(self): + result = self._run(["--help"]) + assert "filter" in result.stdout + assert "server" in result.stdout + assert "stats" in result.stdout + + def test_filter_help(self): + result = self._run(["filter", "--help"]) + assert result.returncode == 0 + assert "list" in result.stdout + + def test_rewrite_help(self): + result = self._run(["rewrite", "--help"]) + assert result.returncode == 0 + + def test_blocking_help(self): + result = self._run(["blocking", "--help"]) + assert result.returncode == 0 + + +# --------------------------------------------------------------------------- +# Docker E2E tests +# --------------------------------------------------------------------------- + +class TestDockerE2E: + CLI_BASE = _resolve_cli("cli-anything-adguardhome") + + def _run_agh(self, args: list[str], agh: dict, check: bool = True) -> subprocess.CompletedProcess: + env = os.environ.copy() + env["AGH_HOST"] = agh["host"] + env["AGH_PORT"] = str(agh["port"]) + env["AGH_USERNAME"] = agh["username"] + env["AGH_PASSWORD"] = agh["password"] + return subprocess.run( + self.CLI_BASE + args, + capture_output=True, text=True, + check=check, + env=env, + ) + + def test_server_status_json(self, agh_docker): + result = self._run_agh(["--json", "server", "status"], agh_docker) + assert result.returncode == 0 + data = json.loads(result.stdout) + print(f"\n Server status: {data}") + assert isinstance(data, dict) + + def test_filter_list_json(self, agh_docker): + result = self._run_agh(["--json", "filter", "list"], agh_docker) + assert result.returncode == 0 + data = json.loads(result.stdout) + print(f"\n Filters: {data}") + assert "filters" in data or isinstance(data, dict) + + def test_rewrite_lifecycle(self, agh_docker): + """Add rewrite, verify in list, remove, verify gone.""" + # Add + add_result = self._run_agh([ + "--json", "rewrite", "add", + "--domain", "test-cli.local", "--answer", "10.0.0.99" + ], agh_docker) + assert add_result.returncode == 0 + print(f"\n Rewrite add: {add_result.stdout.strip()}") + + # List and verify + list_result = self._run_agh(["--json", "rewrite", "list"], agh_docker) + assert list_result.returncode == 0 + rewrites = json.loads(list_result.stdout) + print(f"\n Rewrites: {rewrites}") + domains = [r.get("domain") for r in (rewrites if isinstance(rewrites, list) else [])] + assert "test-cli.local" in domains + + # Remove + rm_result = self._run_agh([ + "--json", "rewrite", "remove", + "--domain", "test-cli.local", "--answer", "10.0.0.99" + ], agh_docker) + assert rm_result.returncode == 0 + + # Verify removed + list_result2 = self._run_agh(["--json", "rewrite", "list"], agh_docker) + rewrites2 = json.loads(list_result2.stdout) + domains2 = [r.get("domain") for r in (rewrites2 if isinstance(rewrites2, list) else [])] + assert "test-cli.local" not in domains2 + print(f"\n Rewrite lifecycle: PASS") + + def test_stats_show_json(self, agh_docker): + result = self._run_agh(["--json", "stats", "show"], agh_docker) + assert result.returncode == 0 + data = json.loads(result.stdout) + print(f"\n Stats keys: {list(data.keys()) if isinstance(data, dict) else 'list'}") + assert isinstance(data, dict) + + def test_config_test(self, agh_docker): + result = self._run_agh(["--json", "config", "test"], agh_docker) + assert result.returncode == 0 + data = json.loads(result.stdout) + print(f"\n Config test: {data}") + assert data.get("connected") is True diff --git a/adguardhome/agent-harness/cli_anything/adguardhome/utils/__init__.py b/adguardhome/agent-harness/cli_anything/adguardhome/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/adguardhome/agent-harness/cli_anything/adguardhome/utils/adguardhome_backend.py b/adguardhome/agent-harness/cli_anything/adguardhome/utils/adguardhome_backend.py new file mode 100644 index 0000000..49b83e1 --- /dev/null +++ b/adguardhome/agent-harness/cli_anything/adguardhome/utils/adguardhome_backend.py @@ -0,0 +1,70 @@ +"""AdGuardHome HTTP API client - wraps all REST calls to the real AdGuardHome service.""" + +from typing import Any + +import requests + + +class AdGuardHomeClient: + """HTTP client for the AdGuardHome REST API.""" + + def __init__(self, host: str = "localhost", port: int = 3000, + username: str = "", password: str = "", https: bool = False): + scheme = "https" if https else "http" + # Auto-detect HTTPS for standard ports + if port == 443: + scheme = "https" + self.base_url = f"{scheme}://{host}:{port}/control" if port not in (80, 443) else f"{scheme}://{host}/control" + self.host = host + self.port = port + self.session = requests.Session() + if username or password: + self.session.auth = (username, password) + self.session.headers.update({"Content-Type": "application/json"}) + + def _url(self, path: str) -> str: + return f"{self.base_url}/{path.lstrip('/')}" + + def _handle_response(self, resp: requests.Response) -> Any: + if not resp.content: + return {} + try: + return resp.json() + except ValueError: + return resp.text + + def _connection_error(self, e: Exception) -> RuntimeError: + return RuntimeError( + f"Cannot connect to AdGuardHome at {self.base_url}.\n" + f"Ensure AdGuardHome is running and accessible.\n" + f"Install: curl -s -S -L https://raw.githubusercontent.com/AdguardTeam/AdGuardHome/master/scripts/install.sh | sh -s -- -v\n" + f"Or Docker: docker run --name adguardhome -p {self.port}:{self.port} adguard/adguardhome\n" + f"Error: {e}" + ) + + def get(self, path: str, params: dict | None = None) -> Any: + """GET request - returns deserialized JSON or raw text.""" + try: + resp = self.session.get(self._url(path), params=params, timeout=10) + resp.raise_for_status() + return self._handle_response(resp) + except requests.exceptions.ConnectionError as e: + raise self._connection_error(e) + + def post(self, path: str, data: Any = None) -> Any: + """POST request - sends JSON body, returns deserialized response.""" + try: + if isinstance(data, (dict, list)): + resp = self.session.post(self._url(path), json=data, timeout=10) + elif isinstance(data, str): + resp = self.session.post( + self._url(path), data=data.encode(), + headers={**dict(self.session.headers), "Content-Type": "text/plain"}, + timeout=10, + ) + else: + resp = self.session.post(self._url(path), timeout=10) + resp.raise_for_status() + return self._handle_response(resp) + except requests.exceptions.ConnectionError as e: + raise self._connection_error(e) diff --git a/adguardhome/agent-harness/cli_anything/adguardhome/utils/repl_skin.py b/adguardhome/agent-harness/cli_anything/adguardhome/utils/repl_skin.py new file mode 100644 index 0000000..bc1fb6d --- /dev/null +++ b/adguardhome/agent-harness/cli_anything/adguardhome/utils/repl_skin.py @@ -0,0 +1,567 @@ +"""cli-anything REPL Skin — Unified terminal interface for all CLI harnesses. + +Copy this file into your CLI package at: + cli_anything//utils/repl_skin.py + +Usage: + from cli_anything..utils.repl_skin import ReplSkin + + skin = ReplSkin("shotcut", version="1.0.0") + skin.print_banner() # auto-detects repo-root or packaged SKILL.md + prompt_text = skin.prompt(project_name="my_video.mlt", modified=True) + skin.success("Project saved") + skin.error("File not found") + skin.warning("Unsaved changes") + skin.info("Processing 24 clips...") + skin.status("Track 1", "3 clips, 00:02:30") + skin.table(headers, rows) + skin.print_goodbye() +""" + +import os +import sys +from pathlib import Path + +# ── ANSI color codes (no external deps for core styling) ────────────── + +_RESET = "\033[0m" +_BOLD = "\033[1m" +_DIM = "\033[2m" +_ITALIC = "\033[3m" +_UNDERLINE = "\033[4m" + +# Brand colors +_CYAN = "\033[38;5;80m" # cli-anything brand cyan +_CYAN_BG = "\033[48;5;80m" +_WHITE = "\033[97m" +_GRAY = "\033[38;5;245m" +_DARK_GRAY = "\033[38;5;240m" +_LIGHT_GRAY = "\033[38;5;250m" + +# Software accent colors — each software gets a unique accent +_ACCENT_COLORS = { + "gimp": "\033[38;5;214m", # warm orange + "blender": "\033[38;5;208m", # deep orange + "inkscape": "\033[38;5;39m", # bright blue + "audacity": "\033[38;5;33m", # navy blue + "libreoffice": "\033[38;5;40m", # green + "obs_studio": "\033[38;5;55m", # purple + "kdenlive": "\033[38;5;69m", # slate blue + "shotcut": "\033[38;5;35m", # teal green +} +_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue + +# Status colors +_GREEN = "\033[38;5;78m" +_YELLOW = "\033[38;5;220m" +_RED = "\033[38;5;196m" +_BLUE = "\033[38;5;75m" +_MAGENTA = "\033[38;5;176m" + +_SKILL_SOURCE_REPO = os.environ.get("CLI_ANYTHING_SKILL_REPO", "HKUDS/CLI-Anything") + +# ── Brand icon ──────────────────────────────────────────────────────── + +# The cli-anything icon: a small colored diamond/chevron mark +_ICON = f"{_CYAN}{_BOLD}◆{_RESET}" +_ICON_SMALL = f"{_CYAN}▸{_RESET}" + +# ── Box drawing characters ──────────────────────────────────────────── + +_H_LINE = "─" +_V_LINE = "│" +_TL = "╭" +_TR = "╮" +_BL = "╰" +_BR = "╯" +_T_DOWN = "┬" +_T_UP = "┴" +_T_RIGHT = "├" +_T_LEFT = "┤" +_CROSS = "┼" + + +def _strip_ansi(text: str) -> str: + """Remove ANSI escape codes for length calculation.""" + import re + return re.sub(r"\033\[[^m]*m", "", text) + + +def _visible_len(text: str) -> int: + """Get visible length of text (excluding ANSI codes).""" + return len(_strip_ansi(text)) + + +def _display_home_path(path: str) -> str: + """Display a path relative to the home directory when possible.""" + expanded = Path(path).expanduser().resolve() + home = Path.home().resolve() + try: + relative = expanded.relative_to(home) + return f"~/{relative.as_posix()}" + except ValueError: + return str(expanded) + + +class ReplSkin: + """Unified REPL skin for cli-anything CLIs. + + Provides consistent branding, prompts, and message formatting + across all CLI harnesses built with the cli-anything methodology. + """ + + def __init__(self, software: str, version: str = "1.0.0", + history_file: str | None = None, skill_path: str | None = None): + """Initialize the REPL skin. + + Args: + software: Software name (e.g., "gimp", "shotcut", "blender"). + version: CLI version string. + history_file: Path for persistent command history. + Defaults to ~/.cli-anything-/history + skill_path: Path to the SKILL.md file for agent discovery. + Auto-detected from the repo-root skills/ tree when present, + otherwise from the package's skills/ directory. + Displayed in banner for AI agents to know where to read skill info. + """ + self.software = software.lower().replace("-", "_") + self.display_name = software.replace("_", " ").title() + self.version = version + software_aliases = {"iterm2_ctl": "iterm2"} + self.skill_slug = software_aliases.get(self.software, self.software).replace("_", "-") + self.skill_id = f"cli-anything-{self.skill_slug}" + self.skill_install_cmd = ( + f"npx skills add {_SKILL_SOURCE_REPO} --skill {self.skill_id} -g -y" + ) + global_skill_root = Path( + os.environ.get("CLI_ANYTHING_GLOBAL_SKILLS_DIR", str(Path.home() / ".agents" / "skills")) + ).expanduser() + self.global_skill_path = str(global_skill_root / self.skill_id / "SKILL.md") + + # Prefer repo-root canonical skills//SKILL.md when running + # inside the CLI-Anything monorepo. Fall back to the packaged + # cli_anything//skills/SKILL.md for installed harnesses. + if skill_path is None: + package_skill = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md" + repo_skill = None + for parent in Path(__file__).resolve().parents: + candidate = parent / "skills" / self.skill_id / "SKILL.md" + if candidate.is_file(): + repo_skill = candidate + break + if repo_skill and repo_skill.is_file(): + skill_path = str(repo_skill) + elif package_skill.is_file(): + skill_path = str(package_skill) + self.skill_path = skill_path + self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT) + + # History file + if history_file is None: + hist_dir = Path.home() / f".cli-anything-{self.software}" + hist_dir.mkdir(parents=True, exist_ok=True) + self.history_file = str(hist_dir / "history") + else: + self.history_file = history_file + + # Detect terminal capabilities + self._color = self._detect_color_support() + + def _detect_color_support(self) -> bool: + """Check if terminal supports color.""" + if os.environ.get("NO_COLOR"): + return False + if os.environ.get("CLI_ANYTHING_NO_COLOR"): + return False + if not hasattr(sys.stdout, "isatty"): + return False + return sys.stdout.isatty() + + def _c(self, code: str, text: str) -> str: + """Apply color code if colors are supported.""" + if not self._color: + return text + return f"{code}{text}{_RESET}" + + # ── Banner ──────────────────────────────────────────────────────── + + def print_banner(self): + """Print the startup banner with branding.""" + import textwrap + + inner = 72 + + def _box_line(content: str) -> str: + """Wrap content in box drawing, padding to inner width.""" + pad = inner - _visible_len(content) + vl = self._c(_DARK_GRAY, _V_LINE) + return f"{vl}{content}{' ' * max(0, pad)}{vl}" + + def _meta_lines(label: str, value: str) -> list[str]: + """Wrap a metadata line for the banner box.""" + icon = self._c(_MAGENTA, "◇") + label_text = self._c(_DARK_GRAY, label) + prefix = f" {icon} {label_text} " + available = max(12, inner - _visible_len(prefix)) + wrapped = textwrap.wrap( + value, + width=available, + break_long_words=True, + break_on_hyphens=False, + ) or [""] + lines = [f"{prefix}{self._c(_LIGHT_GRAY, wrapped[0])}"] + continuation_prefix = " " * _visible_len(prefix) + for chunk in wrapped[1:]: + lines.append(f"{continuation_prefix}{self._c(_LIGHT_GRAY, chunk)}") + return lines + + top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}") + bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}") + + # Title: ◆ cli-anything · Shotcut + icon = self._c(_CYAN + _BOLD, "◆") + brand = self._c(_CYAN + _BOLD, "cli-anything") + dot = self._c(_DARK_GRAY, "·") + name = self._c(self.accent + _BOLD, self.display_name) + title = f" {icon} {brand} {dot} {name}" + + ver = f" {self._c(_DARK_GRAY, f' v{self.version}')}" + tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}" + empty = "" + + meta_lines: list[str] = [] + meta_lines.extend(_meta_lines("Install:", self.skill_install_cmd)) + meta_lines.extend(_meta_lines("Global skill:", _display_home_path(self.global_skill_path))) + print(top) + print(_box_line(title)) + print(_box_line(ver)) + for line in meta_lines: + print(_box_line(line)) + print(_box_line(empty)) + print(_box_line(tip)) + print(bot) + print() + + # ── Prompt ──────────────────────────────────────────────────────── + + def prompt(self, project_name: str = "", modified: bool = False, + context: str = "") -> str: + """Build a styled prompt string for prompt_toolkit or input(). + + Args: + project_name: Current project name (empty if none open). + modified: Whether the project has unsaved changes. + context: Optional extra context to show in prompt. + + Returns: + Formatted prompt string. + """ + parts = [] + + # Icon + if self._color: + parts.append(f"{_CYAN}◆{_RESET} ") + else: + parts.append("> ") + + # Software name + parts.append(self._c(self.accent + _BOLD, self.software)) + + # Project context + if project_name or context: + ctx = context or project_name + mod = "*" if modified else "" + parts.append(f" {self._c(_DARK_GRAY, '[')}") + parts.append(self._c(_LIGHT_GRAY, f"{ctx}{mod}")) + parts.append(self._c(_DARK_GRAY, ']')) + + parts.append(self._c(_GRAY, " ❯ ")) + + return "".join(parts) + + def prompt_tokens(self, project_name: str = "", modified: bool = False, + context: str = ""): + """Build prompt_toolkit formatted text tokens for the prompt. + + Use with prompt_toolkit's FormattedText for proper ANSI handling. + + Returns: + list of (style, text) tuples for prompt_toolkit. + """ + accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff") + tokens = [] + + tokens.append(("class:icon", "◆ ")) + tokens.append(("class:software", self.software)) + + if project_name or context: + ctx = context or project_name + mod = "*" if modified else "" + tokens.append(("class:bracket", " [")) + tokens.append(("class:context", f"{ctx}{mod}")) + tokens.append(("class:bracket", "]")) + + tokens.append(("class:arrow", " ❯ ")) + + return tokens + + def get_prompt_style(self): + """Get a prompt_toolkit Style object matching the skin. + + Returns: + prompt_toolkit.styles.Style + """ + try: + from prompt_toolkit.styles import Style + except ImportError: + return None + + accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff") + + return Style.from_dict({ + "icon": "#5fdfdf bold", # cyan brand color + "software": f"{accent_hex} bold", + "bracket": "#585858", + "context": "#bcbcbc", + "arrow": "#808080", + # Completion menu + "completion-menu.completion": "bg:#303030 #bcbcbc", + "completion-menu.completion.current": f"bg:{accent_hex} #000000", + "completion-menu.meta.completion": "bg:#303030 #808080", + "completion-menu.meta.completion.current": f"bg:{accent_hex} #000000", + # Auto-suggest + "auto-suggest": "#585858", + # Bottom toolbar + "bottom-toolbar": "bg:#1c1c1c #808080", + "bottom-toolbar.text": "#808080", + }) + + # ── Messages ────────────────────────────────────────────────────── + + def success(self, message: str): + """Print a success message with green checkmark.""" + icon = self._c(_GREEN + _BOLD, "✓") + print(f" {icon} {self._c(_GREEN, message)}") + + def error(self, message: str): + """Print an error message with red cross.""" + icon = self._c(_RED + _BOLD, "✗") + print(f" {icon} {self._c(_RED, message)}", file=sys.stderr) + + def warning(self, message: str): + """Print a warning message with yellow triangle.""" + icon = self._c(_YELLOW + _BOLD, "⚠") + print(f" {icon} {self._c(_YELLOW, message)}") + + def info(self, message: str): + """Print an info message with blue dot.""" + icon = self._c(_BLUE, "●") + print(f" {icon} {self._c(_LIGHT_GRAY, message)}") + + def hint(self, message: str): + """Print a subtle hint message.""" + print(f" {self._c(_DARK_GRAY, message)}") + + def section(self, title: str): + """Print a section header.""" + print() + print(f" {self._c(self.accent + _BOLD, title)}") + print(f" {self._c(_DARK_GRAY, _H_LINE * len(title))}") + + # ── Status display ──────────────────────────────────────────────── + + def status(self, label: str, value: str): + """Print a key-value status line.""" + lbl = self._c(_GRAY, f" {label}:") + val = self._c(_WHITE, f" {value}") + print(f"{lbl}{val}") + + def status_block(self, items: dict[str, str], title: str = ""): + """Print a block of status key-value pairs. + + Args: + items: Dict of label -> value pairs. + title: Optional title for the block. + """ + if title: + self.section(title) + + max_key = max(len(k) for k in items) if items else 0 + for label, value in items.items(): + lbl = self._c(_GRAY, f" {label:<{max_key}}") + val = self._c(_WHITE, f" {value}") + print(f"{lbl}{val}") + + def progress(self, current: int, total: int, label: str = ""): + """Print a simple progress indicator. + + Args: + current: Current step number. + total: Total number of steps. + label: Optional label for the progress. + """ + pct = int(current / total * 100) if total > 0 else 0 + bar_width = 20 + filled = int(bar_width * current / total) if total > 0 else 0 + bar = "█" * filled + "░" * (bar_width - filled) + text = f" {self._c(_CYAN, bar)} {self._c(_GRAY, f'{pct:3d}%')}" + if label: + text += f" {self._c(_LIGHT_GRAY, label)}" + print(text) + + # ── Table display ───────────────────────────────────────────────── + + def table(self, headers: list[str], rows: list[list[str]], + max_col_width: int = 40): + """Print a formatted table with box-drawing characters. + + Args: + headers: Column header strings. + rows: List of rows, each a list of cell strings. + max_col_width: Maximum column width before truncation. + """ + if not headers: + return + + # Calculate column widths + col_widths = [min(len(h), max_col_width) for h in headers] + for row in rows: + for i, cell in enumerate(row): + if i < len(col_widths): + col_widths[i] = min( + max(col_widths[i], len(str(cell))), max_col_width + ) + + def pad(text: str, width: int) -> str: + t = str(text)[:width] + return t + " " * (width - len(t)) + + # Header + header_cells = [ + self._c(_CYAN + _BOLD, pad(h, col_widths[i])) + for i, h in enumerate(headers) + ] + sep = self._c(_DARK_GRAY, f" {_V_LINE} ") + header_line = f" {sep.join(header_cells)}" + print(header_line) + + # Separator + sep_parts = [self._c(_DARK_GRAY, _H_LINE * w) for w in col_widths] + sep_line = self._c(_DARK_GRAY, f" {'───'.join([_H_LINE * w for w in col_widths])}") + print(sep_line) + + # Rows + for row in rows: + cells = [] + for i, cell in enumerate(row): + if i < len(col_widths): + cells.append(self._c(_LIGHT_GRAY, pad(str(cell), col_widths[i]))) + row_sep = self._c(_DARK_GRAY, f" {_V_LINE} ") + print(f" {row_sep.join(cells)}") + + # ── Help display ────────────────────────────────────────────────── + + def help(self, commands: dict[str, str]): + """Print a formatted help listing. + + Args: + commands: Dict of command -> description pairs. + """ + self.section("Commands") + max_cmd = max(len(c) for c in commands) if commands else 0 + for cmd, desc in commands.items(): + cmd_styled = self._c(self.accent, f" {cmd:<{max_cmd}}") + desc_styled = self._c(_GRAY, f" {desc}") + print(f"{cmd_styled}{desc_styled}") + print() + + # ── Goodbye ─────────────────────────────────────────────────────── + + def print_goodbye(self): + """Print a styled goodbye message.""" + print(f"\n {_ICON_SMALL} {self._c(_GRAY, 'Goodbye!')}\n") + + # ── Prompt toolkit session factory ──────────────────────────────── + + def create_prompt_session(self): + """Create a prompt_toolkit PromptSession with skin styling. + + Returns: + A configured PromptSession, or None if prompt_toolkit unavailable. + """ + try: + from prompt_toolkit import PromptSession + from prompt_toolkit.history import FileHistory + from prompt_toolkit.auto_suggest import AutoSuggestFromHistory + from prompt_toolkit.formatted_text import FormattedText + + style = self.get_prompt_style() + + session = PromptSession( + history=FileHistory(self.history_file), + auto_suggest=AutoSuggestFromHistory(), + style=style, + enable_history_search=True, + ) + return session + except ImportError: + return None + + def get_input(self, pt_session, project_name: str = "", + modified: bool = False, context: str = "") -> str: + """Get input from user using prompt_toolkit or fallback. + + Args: + pt_session: A prompt_toolkit PromptSession (or None). + project_name: Current project name. + modified: Whether project has unsaved changes. + context: Optional context string. + + Returns: + User input string (stripped). + """ + if pt_session is not None: + from prompt_toolkit.formatted_text import FormattedText + tokens = self.prompt_tokens(project_name, modified, context) + return pt_session.prompt(FormattedText(tokens)).strip() + else: + raw_prompt = self.prompt(project_name, modified, context) + return input(raw_prompt).strip() + + # ── Toolbar builder ─────────────────────────────────────────────── + + def bottom_toolbar(self, items: dict[str, str]): + """Create a bottom toolbar callback for prompt_toolkit. + + Args: + items: Dict of label -> value pairs to show in toolbar. + + Returns: + A callable that returns FormattedText for the toolbar. + """ + def toolbar(): + from prompt_toolkit.formatted_text import FormattedText + parts = [] + for i, (k, v) in enumerate(items.items()): + if i > 0: + parts.append(("class:bottom-toolbar.text", " │ ")) + parts.append(("class:bottom-toolbar.text", f" {k}: ")) + parts.append(("class:bottom-toolbar", v)) + return FormattedText(parts) + return toolbar + + +# ── ANSI 256-color to hex mapping (for prompt_toolkit styles) ───────── + +_ANSI_256_TO_HEX = { + "\033[38;5;33m": "#0087ff", # audacity navy blue + "\033[38;5;35m": "#00af5f", # shotcut teal + "\033[38;5;39m": "#00afff", # inkscape bright blue + "\033[38;5;40m": "#00d700", # libreoffice green + "\033[38;5;55m": "#5f00af", # obs purple + "\033[38;5;69m": "#5f87ff", # kdenlive slate blue + "\033[38;5;75m": "#5fafff", # default sky blue + "\033[38;5;80m": "#5fd7d7", # brand cyan + "\033[38;5;208m": "#ff8700", # blender deep orange + "\033[38;5;214m": "#ffaf00", # gimp warm orange +} diff --git a/adguardhome/agent-harness/setup.py b/adguardhome/agent-harness/setup.py new file mode 100644 index 0000000..f6110ba --- /dev/null +++ b/adguardhome/agent-harness/setup.py @@ -0,0 +1,23 @@ +from setuptools import setup, find_namespace_packages + +setup( + name="cli-anything-adguardhome", + version="1.0.0", + description="CLI harness for AdGuardHome - control your ad blocker from the command line", + packages=find_namespace_packages(include=["cli_anything.*"]), + install_requires=[ + "click>=8.0.0", + "prompt-toolkit>=3.0.0", + "requests>=2.28.0", + ], + entry_points={ + "console_scripts": [ + "cli-anything-adguardhome=cli_anything.adguardhome.adguardhome_cli:main", + ], + }, + package_data={ + "cli_anything.adguardhome": ["skills/*.md"], + }, + include_package_data=True, + python_requires=">=3.10", +) diff --git a/anygen/agent-harness/ANYGEN.md b/anygen/agent-harness/ANYGEN.md new file mode 100644 index 0000000..33dc706 --- /dev/null +++ b/anygen/agent-harness/ANYGEN.md @@ -0,0 +1,226 @@ +# AnyGen: Project-Specific Analysis & SOP + +## Architecture Summary + +AnyGen is a cloud-based asynchronous content generation platform that produces +professional slides (PPT), documents (DOCX), websites, storybooks, diagrams +(SmartDraw), and data analysis reports via a REST API. Unlike local GUI targets, +AnyGen runs entirely server-side — the CLI submits tasks, polls for completion, +and downloads generated files. + +``` +┌──────────────────────────────────────────────────┐ +│ AnyGen Cloud Service │ +│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ +│ │ Slide │ │ Doc │ │ SmartDraw │ │ +│ │ Engine │ │ Engine │ │ Engine │ │ +│ └────┬─────┘ └────┬─────┘ └────────┬─────────┘ │ +│ ┌────┘ ┌─────────┘ ┌─────────────┘ │ +│ │ ┌────┴────┐ ┌─────┴─────┐ ┌───────────────┐ │ +│ │ │ Website │ │ Storybook │ │ Data Analysis │ │ +│ │ │ Engine │ │ Engine │ │ Engine │ │ +│ │ └────┬────┘ └─────┬─────┘ └───────┬───────┘ │ +│ │ │ │ │ │ +│ ┌───────┴─────────────┴───────────────┴───────┐ │ +│ │ Task Orchestration Layer │ │ +│ │ Async queue · status tracking · file store │ │ +│ └──────────────────┬──────────────────────────┘ │ +│ │ │ +│ ┌──────────────────┴──────────────────────────┐ │ +│ │ REST API (OpenAPI 3.1) │ │ +│ │ POST /v1/openapi/tasks │ │ +│ │ GET /v1/openapi/tasks/:id │ │ +│ │ POST /v1/openapi/files/upload │ │ +│ │ POST /v1/openapi/tasks/prepare │ │ +│ └──────────────────┬──────────────────────────┘ │ +└─────────────────────┼────────────────────────────┘ + │ HTTPS + Bearer sk-… + ┌────────────┴─────────────┐ + │ cli-anything-anygen │ + │ Click CLI + REPL │ + │ JSON / human output │ + └──────────────────────────┘ +``` + +## CLI Strategy: HTTP API Client + +AnyGen differs from local GUI targets — there is no local software to invoke. +The CLI acts as a structured HTTP client wrapping the AnyGen OpenAPI: + +1. **requests** — Python HTTP library for all API calls. +2. **Polling loop** — After task creation the CLI polls `GET /v1/openapi/tasks/:id` + at a configurable interval (default 3 s, max 20 min) until `completed` or `failed`. +3. **File download** — On completion the CLI downloads the generated file + (PPTX, DOCX, HTML, SVG, PDF, etc.) to a local path. +4. **File upload** — Reference files can be uploaded via `POST /v1/openapi/files/upload` + to get a `file_token` for use in task creation. +5. **Prepare (multi-turn)** — `POST /v1/openapi/tasks/prepare` enables multi-turn + requirement analysis before creating a task. + +### Why a CLI Wrapper? + +- Agents cannot directly compose multi-step HTTP workflows (auth → upload → prepare → create → poll → download). +- The CLI provides a single `task run` command that orchestrates the full lifecycle. +- Structured `--json` output lets agents parse task IDs, statuses, and file paths. +- The REPL enables interactive exploration of task types and parameters. + +## API Details + +**Base URL:** `https://www.anygen.io` +**Auth:** Bearer token (`sk-…`) via `Authorization` header. + +### Endpoints + +| Method | Endpoint | Description | +|--------|---------------------------------|----------------------------------------------| +| POST | `/v1/openapi/tasks` | Create a new generation task | +| GET | `/v1/openapi/tasks/:id` | Query task status and metadata | +| POST | `/v1/openapi/files/upload` | Upload a reference file → `file_token` | +| POST | `/v1/openapi/tasks/prepare` | Multi-turn requirement analysis | + +### Create Task Request Body + +```json +{ + "auth_token": "Bearer sk-xxx", + "operation": "slide", + "prompt": "Create a quarterly business review presentation", + "language": "en-US", + "slide_count": 10, + "template": "business", + "ratio": "16:9", + "export_format": "pptx", + "file_tokens": ["tk_abc123"], + "files": [] +} +``` + +### Task Status Response + +```json +{ + "task_id": "task_xxx", + "status": "completed", + "progress": 100, + "output": { + "file_url": "https://...", + "file_name": "presentation.pptx", + "thumbnail_url": "https://...", + "task_url": "https://www.anygen.io/task/task_xxx", + "slide_count": 10, + "word_count": 2500 + } +} +``` + +## The Task Format (.anygen-task.json) + +The CLI persists task metadata locally for history and replay: + +```json +{ + "version": "1.0", + "task_id": "task_xxx", + "operation": "slide", + "prompt": "Create a quarterly business review presentation", + "status": "completed", + "created_at": "2026-03-09T12:00:00Z", + "completed_at": "2026-03-09T12:01:23Z", + "output": { + "file_url": "https://...", + "file_name": "presentation.pptx", + "task_url": "https://www.anygen.io/task/task_xxx" + }, + "local_file": "./output/presentation.pptx", + "metadata": { + "file_size": 2048576 + } +} +``` + +## Supported Operation Types + +| Operation | API Value | Output Format | Downloadable File | +|------------------|------------------|---------------|-------------------| +| Slides / PPT | `slide` | PPTX | Yes | +| Documents / DOCX | `doc` | DOCX | Yes | +| SmartDraw | `smart_draw` | drawio / excalidraw | Yes | +| General / Chat | `chat` | — | No (task URL) | +| Storybook | `storybook` | — | No (task URL) | +| Data Analysis | `data_analysis` | — | No (task URL) | +| Website | `website` | — | No (task URL) | + +## Command Map: Agent Action → CLI Command + +| Agent Action | CLI Command | +|--------------------------------------|-------------------------------------------------------------------| +| Create a slide deck | `task create --operation slide --prompt "..." -o task.json` | +| Create a document | `task create --operation doc --prompt "..." -o task.json` | +| Draw a diagram | `task create --operation smart_draw --prompt "..." -o task.json` | +| Full workflow (create→poll→download) | `task run --operation slide --prompt "..." --output ./` | +| Check task status | `task status ` | +| Poll until completion | `task poll [--output ./]` | +| Download result file | `task download --output ./` | +| Download thumbnail | `task thumbnail --output ./` | +| Upload a reference file | `file upload ` | +| Multi-turn requirement analysis | `task prepare --message "..." [--save conv.json]` | +| Configure API key | `config set api_key sk-xxx` | +| View configuration | `config get [key]` | +| View task history | `session history` | +| Undo last operation | `session undo` | + +## Create Task Parameters + +| Parameter | Short | Description | Required | +|----------------|-------|----------------------------------------------------|----------| +| --operation | -o | Operation type (slide/doc/smart_draw/chat/...) | Yes | +| --prompt | -p | Content description | Yes | +| --language | -l | zh-CN / en-US | No | +| --slide-count | -c | Number of PPT pages (slide only) | No | +| --template | -t | PPT template (slide only) | No | +| --ratio | -r | 16:9 / 4:3 (slide only) | No | +| --export-format| -f | pptx/image/thumbnail/docx/drawio/excalidraw | No | +| --file-token | | File token from upload (repeatable) | No | +| --style | -s | Style preference | No | + +## Authentication + +The CLI reads the API key from (in priority order): +1. `--api-key` CLI option +2. `ANYGEN_API_KEY` environment variable +3. `~/.config/anygen/config.json` file + +## Rendering Pipeline + +For AnyGen CLI, "rendering" is server-side generation. The CLI orchestrates: + +### Pipeline Steps: +1. Validate operation type and prompt +2. POST task to AnyGen API with auth header +3. Poll GET /v1/openapi/tasks/:id at 3 s interval (max 20 min) +4. On completion, download file via output `file_url` +5. Save to local path and verify file integrity (size > 0, correct format) + +### Rendering Gap Assessment: **Low** +- All rendering happens server-side — the CLI is a thin orchestration layer +- No local filter translation or format conversion needed +- Risk limited to network issues and API availability +- SmartDraw diagrams may require local Chromium rendering (drawio/excalidraw → PNG) + +## Test Coverage Plan + +1. **Unit tests** (`test_core.py`): Mock HTTP responses, no real API calls + - Task create/status/poll parameter construction + - Config loading (API key from env, file, CLI option) + - Polling logic (timeout, retry, status transitions) + - JSON output formatting + - Session undo/redo with task history + - Error handling (auth failure, rate limit, server error) + - File upload parameter validation + +2. **E2E tests** (`test_full_e2e.py`): Real API calls (require `ANYGEN_API_KEY`) + - Full workflow: create task → poll → download → verify file + - slide and doc operations produce downloadable output + - File format verification (PPTX is valid ZIP, DOCX is valid OOXML) + - CLI subprocess invocation via `_resolve_cli` + - Error scenarios (invalid operation, empty prompt, bad API key) diff --git a/anygen/agent-harness/cli_anything/anygen/README.md b/anygen/agent-harness/cli_anything/anygen/README.md new file mode 100644 index 0000000..215d2c3 --- /dev/null +++ b/anygen/agent-harness/cli_anything/anygen/README.md @@ -0,0 +1,196 @@ +# AnyGen CLI + +A stateful command-line interface for AnyGen OpenAPI — generate professional +slides, documents, websites, diagrams, and more from natural language prompts. +Designed for AI agents and power users. + +## Prerequisites + +- Python 3.10+ +- `requests` (HTTP client) +- `click` (CLI framework) +- AnyGen API key (`sk-xxx`) + +Optional (for interactive REPL): +- `prompt_toolkit` + +## Install Dependencies + +```bash +pip install requests click prompt_toolkit +``` + +## Get an API Key + +1. Go to [anygen.io/home](https://www.anygen.io/home) → Setting → Integration +2. Create an API key (format: `sk-xxx`) +3. Configure it: + +```bash +# Option 1: Config file (recommended) +cli-anything-anygen config set api_key "sk-xxx" + +# Option 2: Environment variable +export ANYGEN_API_KEY="sk-xxx" +``` + +## How to Run + +All commands are run from the `agent-harness/` directory or via the installed command. + +### One-shot Commands + +```bash +# Show help +cli-anything-anygen --help + +# Full workflow: create → poll → download +cli-anything-anygen task run --operation slide --prompt "AI trends presentation" --output ./ + +# Create a task (returns task ID) +cli-anything-anygen task create --operation doc --prompt "Technical design document" + +# Check task status +cli-anything-anygen task status task_xxx + +# Poll until completion (with auto-download) +cli-anything-anygen task poll task_xxx --output ./ + +# Download result file +cli-anything-anygen task download task_xxx --output ./ + +# JSON output for agent consumption +cli-anything-anygen --json task status task_xxx +``` + +### Interactive REPL + +```bash +cli-anything-anygen +``` + +Inside the REPL, type `help` for all available commands. + +## Command Reference + +### Task + +```bash +task create --operation --prompt [options] +task run --operation --prompt [--output dir] [options] +task status +task poll [--output dir] +task download --output +task thumbnail --output +task list [--limit N] [--status completed] +task prepare --message [--file-token tk_xxx] [--save conv.json] +``` + +Operations: `slide`, `doc`, `smart_draw`, `chat`, `storybook`, `data_analysis`, `website` + +Create / run options: +- `--language` / `-l` — zh-CN or en-US +- `--slide-count` / `-c` — Number of slides (slide only) +- `--template` / `-t` — Slide template (slide only) +- `--ratio` / `-r` — 16:9 or 4:3 (slide only) +- `--export-format` / `-f` — pptx/image/thumbnail/docx/drawio/excalidraw +- `--file-token` — File token from upload (repeatable) +- `--style` / `-s` — Style preference + +### File + +```bash +file upload +``` + +### Config + +```bash +config set api_key "sk-xxx" +config set default_language "en-US" +config get [key] +config delete +config path +``` + +### Session + +```bash +session status +session history [--limit N] +session undo +session redo +``` + +## JSON Mode + +Add `--json` before the subcommand for machine-readable output: + +```bash +cli-anything-anygen --json task status task_xxx +cli-anything-anygen --json task list --limit 5 +``` + +## Running Tests + +```bash +cd agent-harness + +# Unit tests (mock HTTP, no API key needed) +python3 -m pytest cli_anything/anygen/tests/test_core.py -v + +# E2E tests (requires ANYGEN_API_KEY) +ANYGEN_API_KEY=sk-xxx python3 -m pytest cli_anything/anygen/tests/test_full_e2e.py -v + +# All tests +python3 -m pytest cli_anything/anygen/tests/ -v +``` + +## Example Workflow + +```bash +# Configure API key +cli-anything-anygen config set api_key "sk-xxx" + +# Upload a reference file +cli-anything-anygen file upload ./quarterly_data.pdf +# Output: ✓ Uploaded: quarterly_data.pdf → token: tk_abc123 + +# Requirement analysis (multi-turn) +cli-anything-anygen task prepare --message "I need a quarterly review slide deck" --save conv.json +# AnyGen asks clarifying questions... + +cli-anything-anygen task prepare --message "Focus on revenue growth, 10 slides" --input conv.json --save conv.json +# Status: ready, suggested operation: slide + +# Create and download in one step +cli-anything-anygen task run \ + --operation slide \ + --prompt "Quarterly business review..." \ + --file-token tk_abc123 \ + --slide-count 10 \ + --style "business formal" \ + --output ./output/ + +# Or step-by-step +cli-anything-anygen task create --operation slide --prompt "..." +# Task ID: task_xxx + +cli-anything-anygen task poll task_xxx --output ./output/ +# ✓ Downloaded: ./output/presentation.pptx (2,048,576 bytes) + +# Verify the file +cli-anything-anygen --json task status task_xxx +``` + +## Supported Operations + +| Operation | Type | Output | Downloadable | +|-----------|------|--------|-------------| +| Slides | `slide` | PPTX | Yes | +| Documents | `doc` | DOCX | Yes | +| Diagrams | `smart_draw` | drawio/excalidraw | Yes | +| General | `chat` | — | No (URL) | +| Storybooks | `storybook` | — | No (URL) | +| Data Analysis | `data_analysis` | — | No (URL) | +| Websites | `website` | — | No (URL) | diff --git a/anygen/agent-harness/cli_anything/anygen/__init__.py b/anygen/agent-harness/cli_anything/anygen/__init__.py new file mode 100644 index 0000000..064c918 --- /dev/null +++ b/anygen/agent-harness/cli_anything/anygen/__init__.py @@ -0,0 +1 @@ +"""AnyGen CLI - Generate docs, slides, websites and more via AnyGen cloud API.""" diff --git a/anygen/agent-harness/cli_anything/anygen/__main__.py b/anygen/agent-harness/cli_anything/anygen/__main__.py new file mode 100644 index 0000000..e997e8a --- /dev/null +++ b/anygen/agent-harness/cli_anything/anygen/__main__.py @@ -0,0 +1,3 @@ +"""Allow running as python3 -m cli_anything.anygen""" +from cli_anything.anygen.anygen_cli import main +main() diff --git a/anygen/agent-harness/cli_anything/anygen/anygen_cli.py b/anygen/agent-harness/cli_anything/anygen/anygen_cli.py new file mode 100644 index 0000000..1ccc556 --- /dev/null +++ b/anygen/agent-harness/cli_anything/anygen/anygen_cli.py @@ -0,0 +1,523 @@ +#!/usr/bin/env python3 +"""AnyGen CLI — Generate docs, slides, websites and more via AnyGen cloud API. + +Usage: + # One-shot commands + cli-anything-anygen task run --operation slide --prompt "AI trends presentation" --output ./ + cli-anything-anygen task create --operation doc --prompt "Technical report" + cli-anything-anygen task status + + # Interactive REPL + cli-anything-anygen +""" + +import sys +import os +import json +import click +from typing import Optional + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from cli_anything.anygen.core.session import Session +from cli_anything.anygen.core import task as task_mod +from cli_anything.anygen.core import export as export_mod +from cli_anything.anygen.utils.anygen_backend import ( + get_api_key, + load_config, + save_config, + VALID_OPERATIONS, + DOWNLOADABLE_OPERATIONS, +) + +_session: Optional[Session] = None +_json_output = False +_repl_mode = False +_api_key: Optional[str] = None + + +def get_session() -> Session: + global _session + if _session is None: + from pathlib import Path + sf = str(Path.home() / ".cli-anything-anygen" / "session.json") + _session = Session(session_file=sf) + return _session + + +def output(data, message: str = ""): + if _json_output: + click.echo(json.dumps(data, indent=2, default=str)) + else: + if message: + click.echo(message) + if isinstance(data, dict): + _print_dict(data) + elif isinstance(data, list): + _print_list(data) + else: + click.echo(str(data)) + + +def _print_dict(d: dict, indent: int = 0): + prefix = " " * indent + for k, v in d.items(): + if isinstance(v, dict): + click.echo(f"{prefix}{k}:") + _print_dict(v, indent + 1) + elif isinstance(v, list): + click.echo(f"{prefix}{k}:") + _print_list(v, indent + 1) + else: + click.echo(f"{prefix}{k}: {v}") + + +def _print_list(items: list, indent: int = 0): + prefix = " " * indent + for i, item in enumerate(items): + if isinstance(item, dict): + click.echo(f"{prefix}[{i}]") + _print_dict(item, indent + 1) + else: + click.echo(f"{prefix}- {item}") + + +def handle_error(func): + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except (FileNotFoundError, ValueError, RuntimeError, TimeoutError) as e: + if _json_output: + click.echo(json.dumps({"error": str(e), "type": type(e).__name__})) + else: + click.echo(f"Error: {e}", err=True) + if not _repl_mode: + sys.exit(1) + wrapper.__name__ = func.__name__ + wrapper.__doc__ = func.__doc__ + return wrapper + + +# ── Main CLI Group ────────────────────────────────────────────── + +@click.group(invoke_without_command=True) +@click.option("--json", "use_json", is_flag=True, help="Output as JSON") +@click.option("--api-key", "api_key_opt", type=str, default=None, + help="AnyGen API key (sk-xxx)") +@click.pass_context +def cli(ctx, use_json, api_key_opt): + """AnyGen CLI — Generate docs, slides, websites and more.""" + global _json_output, _api_key + _json_output = use_json + _api_key = get_api_key(api_key_opt) + ctx.ensure_object(dict) + + if ctx.invoked_subcommand is None: + ctx.invoke(repl) + + +# ── Task Command Group ────────────────────────────────────────── + +@cli.group() +def task(): + """Task management — create, poll, download, and run tasks.""" + pass + + +@task.command("create") +@click.option("--operation", "-o", required=True, + type=click.Choice(VALID_OPERATIONS, case_sensitive=False), + help="Operation type") +@click.option("--prompt", "-p", required=True, help="Content prompt") +@click.option("--language", "-l", default=None, help="Language (zh-CN, en-US)") +@click.option("--slide-count", "-c", type=int, default=None, help="Number of slides") +@click.option("--template", "-t", default=None, help="Slide template") +@click.option("--ratio", "-r", type=click.Choice(["16:9", "4:3"]), default=None, help="Slide ratio") +@click.option("--export-format", "-f", default=None, help="Export format") +@click.option("--file-token", multiple=True, help="File token from upload (repeatable)") +@click.option("--style", "-s", default=None, help="Style preference") +@handle_error +def task_create(operation, prompt, language, slide_count, template, ratio, + export_format, file_token, style): + """Create a generation task.""" + sess = get_session() + result = task_mod.create_task( + _api_key, operation, prompt, + language=language, slide_count=slide_count, template=template, + ratio=ratio, export_format=export_format, + file_tokens=list(file_token) if file_token else None, + style=style, + ) + sess.record("task create", {"operation": operation, "prompt": prompt}, result) + output(result, f"✓ Task created: {result['task_id']}") + + +@task.command("status") +@click.argument("task_id") +@handle_error +def task_status(task_id): + """Query task status (non-blocking).""" + result = task_mod.query_task(_api_key, task_id) + status = result.get("status") + progress = result.get("progress", 0) + out = { + "task_id": task_id, + "status": status, + "progress": progress, + } + if status == "completed": + o = result.get("output", {}) + if o.get("file_name"): + out["file_name"] = o["file_name"] + if o.get("task_url"): + out["task_url"] = o["task_url"] + output(out, f"Task {task_id}: {status} ({progress}%)") + + +@task.command("poll") +@click.argument("task_id") +@click.option("--output", "-o", "output_dir", default=None, + help="Output directory for auto-download on completion") +@handle_error +def task_poll(task_id, output_dir): + """Poll task until completion (blocking).""" + def on_progress(status, pct): + if not _json_output: + click.echo(f" ● {status}: {pct}%") + + result = task_mod.poll_task(_api_key, task_id, on_progress=on_progress) + sess = get_session() + sess.record("task poll", {"task_id": task_id}, {"status": result.get("status")}) + + if output_dir and result.get("status") == "completed": + dl = task_mod.download_file(_api_key, task_id, output_dir) + output(dl, f"✓ Downloaded: {dl['local_path']} ({dl['file_size']:,} bytes)") + else: + output(result, f"✓ Task {task_id}: {result.get('status')}") + + +@task.command("download") +@click.argument("task_id") +@click.option("--output", "-o", "output_dir", required=True, help="Output directory") +@handle_error +def task_download(task_id, output_dir): + """Download the generated file for a completed task.""" + dl = task_mod.download_file(_api_key, task_id, output_dir) + sess = get_session() + sess.record("task download", {"task_id": task_id}, dl) + output(dl, f"✓ Downloaded: {dl['local_path']} ({dl['file_size']:,} bytes)") + + +@task.command("thumbnail") +@click.argument("task_id") +@click.option("--output", "-o", "output_dir", required=True, help="Output directory") +@handle_error +def task_thumbnail(task_id, output_dir): + """Download thumbnail image for a completed task.""" + dl = task_mod.download_thumbnail(_api_key, task_id, output_dir) + output(dl, f"✓ Thumbnail saved: {dl['local_path']}") + + +@task.command("run") +@click.option("--operation", "-o", required=True, + type=click.Choice(VALID_OPERATIONS, case_sensitive=False), + help="Operation type") +@click.option("--prompt", "-p", required=True, help="Content prompt") +@click.option("--output", "output_dir", default=None, help="Output directory") +@click.option("--language", "-l", default=None, help="Language (zh-CN, en-US)") +@click.option("--slide-count", "-c", type=int, default=None, help="Number of slides") +@click.option("--template", "-t", default=None, help="Slide template") +@click.option("--ratio", "-r", type=click.Choice(["16:9", "4:3"]), default=None) +@click.option("--export-format", "-f", default=None, help="Export format") +@click.option("--file-token", multiple=True, help="File token (repeatable)") +@click.option("--style", "-s", default=None, help="Style preference") +@handle_error +def task_run(operation, prompt, output_dir, language, slide_count, template, + ratio, export_format, file_token, style): + """Full workflow: create → poll → download.""" + def on_progress(status, pct): + if not _json_output: + click.echo(f" ● {status}: {pct}%") + + result = task_mod.run_full_workflow( + _api_key, operation, prompt, output_dir, + on_progress=on_progress, + language=language, slide_count=slide_count, template=template, + ratio=ratio, export_format=export_format, + file_tokens=list(file_token) if file_token else None, + style=style, + ) + sess = get_session() + sess.record("task run", {"operation": operation, "prompt": prompt}, result) + + if result.get("local_path"): + output(result, f"✓ Completed! File: {result['local_path']} ({result.get('file_size', 0):,} bytes)") + else: + output(result, f"✓ Completed! View at: {result.get('task_url', 'N/A')}") + + +@task.command("list") +@click.option("--limit", "-n", type=int, default=20, help="Max number of tasks") +@click.option("--status", "status_filter", default=None, help="Filter by status") +@handle_error +def task_list(limit, status_filter): + """List locally cached task records.""" + records = task_mod.list_task_records(limit=limit, status_filter=status_filter) + if not records: + output([], "No tasks found.") + return + output(records, f"Found {len(records)} task(s):") + + +@task.command("prepare") +@click.option("--message", "-m", required=True, help="User message") +@click.option("--file-token", multiple=True, help="File token (repeatable)") +@click.option("--input", "input_file", default=None, help="Load conversation from JSON") +@click.option("--save", "save_file", default=None, help="Save conversation to JSON") +@handle_error +def task_prepare(message, file_token, input_file, save_file): + """Multi-turn requirement analysis before creating a task.""" + messages = [] + loaded_file_tokens = set() + + if input_file: + with open(input_file) as f: + data = json.load(f) + messages = data.get("messages", []) + loaded_file_tokens = set(data.get("file_tokens", [])) + + ft_list = list(file_token) if file_token else [] + all_tokens = ft_list + list(loaded_file_tokens) + + content = [{"type": "text", "text": message}] + for ft in ft_list: + if ft not in loaded_file_tokens: + content.append({"type": "file", "file_token": ft}) + messages.append({"role": "user", "content": content}) + + result = task_mod.prepare_task( + _api_key, messages, + file_tokens=all_tokens if all_tokens else None, + ) + + if save_file: + save_data = { + "messages": result.get("messages", messages), + "file_tokens": all_tokens, + "status": result.get("status"), + "suggested_task_params": result.get("suggested_task_params"), + } + with open(save_file, "w") as f: + json.dump(save_data, f, indent=2, ensure_ascii=False) + + reply = result.get("reply", "") + status = result.get("status", "collecting") + suggested = result.get("suggested_task_params") + + out = {"reply": reply, "status": status} + if suggested: + out["suggested_task_params"] = suggested + + msg = f"AnyGen: {reply}\nStatus: {status}" + if suggested: + msg += f"\nSuggested operation: {suggested.get('operation', 'N/A')}" + output(out, msg) + + +# ── File Command Group ────────────────────────────────────────── + +@cli.group() +def file(): + """File operations — upload reference files.""" + pass + + +@file.command("upload") +@click.argument("path", type=click.Path(exists=True)) +@handle_error +def file_upload(path): + """Upload a reference file to get a file_token.""" + result = task_mod.upload_file(_api_key, path) + sess = get_session() + sess.record("file upload", {"path": path}, result) + output(result, f"✓ Uploaded: {result['filename']} → token: {result['file_token']}") + + +# ── Config Command Group ──────────────────────────────────────── + +@cli.group() +def config(): + """Configuration management — API key and settings.""" + pass + + +@config.command("set") +@click.argument("key", type=click.Choice(["api_key", "default_language"])) +@click.argument("value") +def config_set(key, value): + """Set a configuration value.""" + cfg = load_config() + cfg[key] = value + save_config(cfg) + display = value[:10] + "..." if key == "api_key" and len(value) > 10 else value + output({"key": key, "value": display}, f"✓ Set {key} = {display}") + + +@config.command("get") +@click.argument("key", required=False) +def config_get(key): + """Get a configuration value (or show all).""" + cfg = load_config() + if key: + val = cfg.get(key) + if val: + if key == "api_key" and len(val) > 10: + val = val[:10] + "..." + output({"key": key, "value": val}, f"{key} = {val}") + else: + output({"key": key, "value": None}, f"{key} is not set") + else: + if cfg: + masked = {} + for k, v in cfg.items(): + masked[k] = v[:10] + "..." if k == "api_key" and len(v) > 10 else v + output(masked) + else: + output({}, "No configuration set") + + +@config.command("delete") +@click.argument("key") +def config_delete(key): + """Delete a configuration value.""" + cfg = load_config() + if key in cfg: + del cfg[key] + save_config(cfg) + output({"deleted": key}, f"✓ Deleted {key}") + else: + output({"error": f"{key} not found"}, f"{key} not found in config") + + +@config.command("path") +def config_path(): + """Show the config file path.""" + from cli_anything.anygen.utils.anygen_backend import CONFIG_FILE + output({"path": str(CONFIG_FILE)}, f"Config file: {CONFIG_FILE}") + + +# ── Session Command Group ─────────────────────────────────────── + +@cli.group() +def session(): + """Session management — history, undo, redo.""" + pass + + +@session.command("status") +def session_status(): + """Show session status.""" + sess = get_session() + output(sess.status()) + + +@session.command("history") +@click.option("--limit", "-n", type=int, default=20, help="Max entries") +def session_history(limit): + """Show command history.""" + sess = get_session() + entries = sess.history(limit=limit) + if not entries: + output([], "No history.") + return + output(entries, f"History ({len(entries)} entries):") + + +@session.command("undo") +def session_undo(): + """Undo last command.""" + sess = get_session() + entry = sess.undo() + if entry: + output(entry.to_dict(), f"✓ Undone: {entry.command}") + else: + output({"error": "Nothing to undo"}, "Nothing to undo") + + +@session.command("redo") +def session_redo(): + """Redo last undone command.""" + sess = get_session() + entry = sess.redo() + if entry: + output(entry.to_dict(), f"✓ Redone: {entry.command}") + else: + output({"error": "Nothing to redo"}, "Nothing to redo") + + +# ── REPL ──────────────────────────────────────────────────────── + +@cli.command("repl", hidden=True) +def repl(): + """Enter interactive REPL mode.""" + global _repl_mode + _repl_mode = True + + from cli_anything.anygen.utils.repl_skin import ReplSkin + + skin = ReplSkin("anygen", version="1.0.0") + skin.print_banner() + + pt_session = skin.create_prompt_session() + + commands = { + "task create": "Create a generation task", + "task run": "Full workflow: create → poll → download", + "task status ": "Check task status", + "task poll ": "Poll until completion", + "task download ": "Download generated file", + "task list": "List local task history", + "task prepare": "Multi-turn requirement analysis", + "file upload ": "Upload a reference file", + "config set ": "Set configuration", + "config get [key]": "Show configuration", + "session history": "Show command history", + "session undo": "Undo last command", + "session redo": "Redo last undone command", + "help": "Show this help", + "quit / exit": "Exit REPL", + } + + while True: + try: + line = skin.get_input(pt_session, context="anygen") + except (EOFError, KeyboardInterrupt): + skin.print_goodbye() + break + + if not line: + continue + if line in ("quit", "exit", "q"): + skin.print_goodbye() + break + if line == "help": + skin.help(commands) + continue + + parts = line.split() + try: + cli.main(parts, standalone_mode=False) + except SystemExit: + pass + except click.exceptions.UsageError as e: + skin.error(str(e)) + except Exception as e: + skin.error(str(e)) + + +def main(): + cli() + + +if __name__ == "__main__": + main() diff --git a/anygen/agent-harness/cli_anything/anygen/core/__init__.py b/anygen/agent-harness/cli_anything/anygen/core/__init__.py new file mode 100644 index 0000000..a4dddac --- /dev/null +++ b/anygen/agent-harness/cli_anything/anygen/core/__init__.py @@ -0,0 +1 @@ +"""AnyGen CLI core modules.""" diff --git a/anygen/agent-harness/cli_anything/anygen/core/export.py b/anygen/agent-harness/cli_anything/anygen/core/export.py new file mode 100644 index 0000000..110f4d7 --- /dev/null +++ b/anygen/agent-harness/cli_anything/anygen/core/export.py @@ -0,0 +1,101 @@ +"""Export utilities — file format verification for downloaded AnyGen outputs.""" + +import zipfile +from pathlib import Path + + +def verify_file(file_path: str) -> dict: + """Verify a downloaded file's integrity and format. + + Returns {"valid": bool, "format": str, "file_size": int, "details": str}. + """ + path = Path(file_path) + if not path.exists(): + return {"valid": False, "format": "unknown", "file_size": 0, "details": "File not found"} + + size = path.stat().st_size + if size == 0: + return {"valid": False, "format": "unknown", "file_size": 0, "details": "Empty file"} + + suffix = path.suffix.lower() + + with open(path, "rb") as f: + header = f.read(8) + + if suffix in (".pptx", ".docx", ".xlsx"): + is_zip = header[:4] == b"PK\x03\x04" + if is_zip: + try: + with zipfile.ZipFile(path) as zf: + names = zf.namelist() + has_content_types = "[Content_Types].xml" in names + fmt = "OOXML" if has_content_types else "ZIP" + return { + "valid": has_content_types, + "format": fmt, + "file_size": size, + "details": f"Valid {suffix.upper().lstrip('.')} ({len(names)} entries)", + } + except zipfile.BadZipFile: + return {"valid": False, "format": "corrupt_zip", "file_size": size, "details": "Bad ZIP"} + return {"valid": False, "format": "not_zip", "file_size": size, "details": "Expected ZIP/OOXML"} + + if suffix == ".pdf": + is_pdf = header[:5] == b"%PDF-" + return { + "valid": is_pdf, + "format": "PDF", + "file_size": size, + "details": "Valid PDF" if is_pdf else "Missing %PDF- header", + } + + if suffix == ".png": + is_png = header[:8] == b"\x89PNG\r\n\x1a\n" + return { + "valid": is_png, + "format": "PNG", + "file_size": size, + "details": "Valid PNG" if is_png else "Bad PNG header", + } + + if suffix == ".svg": + try: + text = path.read_text(encoding="utf-8")[:500] + is_svg = " tag", + } + except UnicodeDecodeError: + return {"valid": False, "format": "binary", "file_size": size, "details": "Not valid SVG text"} + + if suffix in (".xml", ".drawio"): + try: + text = path.read_text(encoding="utf-8")[:500] + is_xml = text.strip().startswith(" None: + """Atomically write JSON with exclusive file locking.""" + try: + f = open(path, "r+") + except FileNotFoundError: + os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) + f = open(path, "w") + with f: + _locked = False + try: + import fcntl + fcntl.flock(f.fileno(), fcntl.LOCK_EX) + _locked = True + except (ImportError, OSError): + pass + try: + f.seek(0) + f.truncate() + json.dump(data, f, **dump_kwargs) + f.flush() + finally: + if _locked: + import fcntl + fcntl.flock(f.fileno(), fcntl.LOCK_UN) + + +@dataclass +class HistoryEntry: + command: str + args: dict + timestamp: str = "" + result: dict | None = None + + def __post_init__(self): + if not self.timestamp: + self.timestamp = datetime.now(timezone.utc).isoformat() + + def to_dict(self) -> dict: + return { + "command": self.command, + "args": self.args, + "timestamp": self.timestamp, + "result": self.result, + } + + @classmethod + def from_dict(cls, d: dict) -> "HistoryEntry": + return cls( + command=d["command"], + args=d.get("args", {}), + timestamp=d.get("timestamp", ""), + result=d.get("result"), + ) + + +class Session: + """Manages command history and undo/redo for the AnyGen CLI REPL.""" + + def __init__(self, session_file: str | None = None): + self._history: list[HistoryEntry] = [] + self._redo_stack: list[HistoryEntry] = [] + self._session_file = session_file + + if session_file: + self._load(session_file) + + def record(self, command: str, args: dict, result: dict | None = None): + entry = HistoryEntry(command=command, args=args, result=result) + self._history.append(entry) + self._redo_stack.clear() + self._auto_save() + + def undo(self) -> HistoryEntry | None: + if not self._history: + return None + entry = self._history.pop() + self._redo_stack.append(entry) + self._auto_save() + return entry + + def redo(self) -> HistoryEntry | None: + if not self._redo_stack: + return None + entry = self._redo_stack.pop() + self._history.append(entry) + self._auto_save() + return entry + + def history(self, limit: int = 20) -> list[dict]: + entries = self._history[-limit:] if limit else self._history + return [e.to_dict() for e in entries] + + @property + def history_count(self) -> int: + return len(self._history) + + @property + def can_undo(self) -> bool: + return len(self._history) > 0 + + @property + def can_redo(self) -> bool: + return len(self._redo_stack) > 0 + + def status(self) -> dict: + return { + "history_count": self.history_count, + "can_undo": self.can_undo, + "can_redo": self.can_redo, + "redo_count": len(self._redo_stack), + } + + def _auto_save(self): + if self._session_file: + self.save(self._session_file) + + def save(self, path: str): + data = { + "history": [e.to_dict() for e in self._history], + "redo_stack": [e.to_dict() for e in self._redo_stack], + } + _locked_save_json(path, data, indent=2, sort_keys=True, default=str) + + def _load(self, path: str): + p = Path(path) + if not p.exists(): + return + try: + with open(p) as f: + data = json.load(f) + self._history = [HistoryEntry.from_dict(e) for e in data.get("history", [])] + self._redo_stack = [HistoryEntry.from_dict(e) for e in data.get("redo_stack", [])] + except (json.JSONDecodeError, IOError, KeyError): + pass diff --git a/anygen/agent-harness/cli_anything/anygen/core/task.py b/anygen/agent-harness/cli_anything/anygen/core/task.py new file mode 100644 index 0000000..0c9296b --- /dev/null +++ b/anygen/agent-harness/cli_anything/anygen/core/task.py @@ -0,0 +1,159 @@ +"""Task management — create, query, poll, download AnyGen tasks. + +Thin wrappers around the backend that add local task history persistence. +""" + +import json +import os +from datetime import datetime, timezone +from pathlib import Path + +from cli_anything.anygen.utils.anygen_backend import ( + DOWNLOADABLE_OPERATIONS, + VALID_OPERATIONS, + create_task as _api_create, + download_file as _api_download, + download_thumbnail as _api_thumbnail, + poll_task as _api_poll, + query_task as _api_query, + run_full_workflow as _api_run, + upload_file as _api_upload, + prepare_task as _api_prepare, +) + +TASK_HISTORY_DIR = Path.home() / ".cli-anything-anygen" / "tasks" + + +def _save_task_record(task_id: str, record: dict): + TASK_HISTORY_DIR.mkdir(parents=True, exist_ok=True) + path = TASK_HISTORY_DIR / f"{task_id}.json" + with open(path, "w") as f: + json.dump(record, f, indent=2, default=str) + + +def _load_task_record(task_id: str) -> dict | None: + path = TASK_HISTORY_DIR / f"{task_id}.json" + if not path.exists(): + return None + with open(path) as f: + return json.load(f) + + +def list_task_records(limit: int = 20, status_filter: str | None = None) -> list[dict]: + """List locally cached task records, newest first.""" + if not TASK_HISTORY_DIR.exists(): + return [] + records = [] + for p in sorted(TASK_HISTORY_DIR.glob("*.json"), key=lambda x: x.stat().st_mtime, reverse=True): + try: + with open(p) as f: + rec = json.load(f) + if status_filter and rec.get("status") != status_filter: + continue + records.append(rec) + if len(records) >= limit: + break + except (json.JSONDecodeError, IOError): + continue + return records + + +def create_task( + api_key: str, + operation: str, + prompt: str, + **kwargs, +) -> dict: + """Create task and persist a local record. Returns {"task_id", "task_url"}.""" + result = _api_create(api_key, operation, prompt, **kwargs) + record = { + "version": "1.0", + "task_id": result["task_id"], + "operation": operation, + "prompt": prompt, + "status": "pending", + "created_at": datetime.now(timezone.utc).isoformat(), + "task_url": result.get("task_url"), + } + _save_task_record(result["task_id"], record) + return result + + +def query_task(api_key: str, task_id: str, **kwargs) -> dict: + """Query task status and update local record.""" + task = _api_query(api_key, task_id, **kwargs) + rec = _load_task_record(task_id) or {"task_id": task_id} + rec["status"] = task.get("status") + rec["progress"] = task.get("progress", 0) + if task.get("output"): + rec["output"] = task["output"] + _save_task_record(task_id, rec) + return task + + +def poll_task(api_key: str, task_id: str, on_progress=None, **kwargs) -> dict: + """Poll until completion and update local record.""" + task = _api_poll(api_key, task_id, on_progress=on_progress, **kwargs) + rec = _load_task_record(task_id) or {"task_id": task_id} + rec["status"] = task.get("status") + rec["completed_at"] = datetime.now(timezone.utc).isoformat() + if task.get("output"): + rec["output"] = task["output"] + _save_task_record(task_id, rec) + return task + + +def download_file(api_key: str, task_id: str, output_dir: str, **kwargs) -> dict: + """Download file and update local record with path.""" + dl = _api_download(api_key, task_id, output_dir, **kwargs) + rec = _load_task_record(task_id) or {"task_id": task_id} + rec["local_file"] = dl["local_path"] + rec["metadata"] = {"file_size": dl["file_size"]} + _save_task_record(task_id, rec) + return dl + + +def download_thumbnail(api_key: str, task_id: str, output_dir: str, **kwargs) -> dict: + """Download thumbnail and return path info.""" + return _api_thumbnail(api_key, task_id, output_dir, **kwargs) + + +def upload_file(api_key: str, file_path: str, **kwargs) -> dict: + """Upload a reference file. Returns {"file_token", "filename", "file_size"}.""" + return _api_upload(api_key, file_path, **kwargs) + + +def prepare_task(api_key: str, messages: list[dict], **kwargs) -> dict: + """Multi-turn requirement analysis. Returns prepare API response.""" + return _api_prepare(api_key, messages, **kwargs) + + +def run_full_workflow( + api_key: str, + operation: str, + prompt: str, + output_dir: str | None = None, + on_progress=None, + **kwargs, +) -> dict: + """Full workflow: create → poll → download. Returns combined result dict.""" + result = _api_run( + api_key, operation, prompt, output_dir, + on_progress=on_progress, **kwargs, + ) + rec = { + "version": "1.0", + "task_id": result["task_id"], + "operation": operation, + "prompt": prompt, + "status": result.get("status", "completed"), + "created_at": datetime.now(timezone.utc).isoformat(), + "completed_at": datetime.now(timezone.utc).isoformat(), + "task_url": result.get("task_url"), + "output": result.get("output", {}), + } + if result.get("local_path"): + rec["local_file"] = result["local_path"] + rec["metadata"] = {"file_size": result.get("file_size", 0)} + _save_task_record(result["task_id"], rec) + return result diff --git a/anygen/agent-harness/cli_anything/anygen/skills/SKILL.md b/anygen/agent-harness/cli_anything/anygen/skills/SKILL.md new file mode 100644 index 0000000..1243042 --- /dev/null +++ b/anygen/agent-harness/cli_anything/anygen/skills/SKILL.md @@ -0,0 +1,174 @@ +--- +name: >- + cli-anything-anygen +description: >- + Command-line interface for Anygen - A stateful command-line interface for AnyGen OpenAPI — generate professional slides, documents, webs... +--- + +# cli-anything-anygen + +A stateful command-line interface for AnyGen OpenAPI — generate professional slides, documents, websites, diagrams, and more from natural language prompts. Designed for AI agents and power users. + +## Installation + +This CLI is installed as part of the cli-anything-anygen package: + +```bash +pip install cli-anything-anygen +``` + +**Prerequisites:** +- Python 3.10+ +- anygen must be installed on your system + + +## Usage + +### Basic Commands + +```bash +# Show help +cli-anything-anygen --help + +# Start interactive REPL mode +cli-anything-anygen + +# Create a new project +cli-anything-anygen project new -o project.json + +# Run with JSON output (for agent consumption) +cli-anything-anygen --json project info -p project.json +``` + +### REPL Mode + +When invoked without a subcommand, the CLI enters an interactive REPL session: + +```bash +cli-anything-anygen +# Enter commands interactively with tab-completion and history +``` + + +## Command Groups + + +### Task + +Task management — create, poll, download, and run tasks. + +| Command | Description | +|---------|-------------| +| `create` | Create a generation task | +| `status` | Query task status (non-blocking) | +| `poll` | Poll task until completion (blocking) | +| `download` | Download the generated file for a completed task | +| `thumbnail` | Download thumbnail image for a completed task | +| `run` | Full workflow: create, poll, download | +| `list` | List locally cached task records | +| `prepare` | Multi-turn requirement analysis before creating a task | + + +### File + +File operations — upload reference files. + +| Command | Description | +|---------|-------------| +| `upload` | Upload a reference file to get a file_token | + + +### Config + +Configuration management — API key and settings. + +| Command | Description | +|---------|-------------| +| `set` | Set a configuration value | +| `get` | Get a configuration value (or show all) | +| `delete` | Delete a configuration value | +| `path` | Show the config file path | + + +### Session + +Session management — history, undo, redo. + +| Command | Description | +|---------|-------------| +| `status` | Show session status | +| `history` | Show command history | +| `undo` | Undo last command | +| `redo` | Redo last undone command | + + + + +## Examples + + +### Create a New Project + +Create a new anygen project file. + +```bash +cli-anything-anygen project new -o myproject.json +# Or with JSON output for programmatic use +cli-anything-anygen --json project new -o myproject.json +``` + + +### Interactive REPL Session + +Start an interactive session with undo/redo support. + +```bash +cli-anything-anygen +# Enter commands interactively +# Use 'help' to see available commands +# Use 'undo' and 'redo' for history navigation +``` + + +## State Management + +The CLI maintains session state with: + +- **Undo/Redo**: Up to 50 levels of history +- **Project persistence**: Save/load project state as JSON +- **Session tracking**: Track modifications and changes + +## Output Formats + +All commands support dual output modes: + +- **Human-readable** (default): Tables, colors, formatted text +- **Machine-readable** (`--json` flag): Structured JSON for agent consumption + +```bash +# Human output +cli-anything-anygen project info -p project.json + +# JSON output for agents +cli-anything-anygen --json project info -p project.json +``` + +## For AI Agents + +When using this CLI programmatically: + +1. **Always use `--json` flag** for parseable output +2. **Check return codes** - 0 for success, non-zero for errors +3. **Parse stderr** for error messages on failure +4. **Use absolute paths** for all file operations +5. **Verify outputs exist** after export operations + +## More Information + +- Full documentation: See README.md in the package +- Test coverage: See TEST.md in the package +- Methodology: See HARNESS.md in the cli-anything-plugin + +## Version + +1.0.0 \ No newline at end of file diff --git a/anygen/agent-harness/cli_anything/anygen/tests/TEST.md b/anygen/agent-harness/cli_anything/anygen/tests/TEST.md new file mode 100644 index 0000000..ed33e22 --- /dev/null +++ b/anygen/agent-harness/cli_anything/anygen/tests/TEST.md @@ -0,0 +1,115 @@ +# AnyGen CLI Harness - Test Documentation + +## Test Inventory + +| File | Test Classes | Test Count | Focus | +|------|-------------|------------|-------| +| `test_core.py` | 6 | 45 | Unit tests for config, task creation, polling, session, export verification | +| `test_full_e2e.py` | 3 | 18 | E2E API tests + CLI subprocess tests | +| **Total** | **9** | **63** | | + +## Unit Tests (`test_core.py`) + +All unit tests use mocked HTTP responses — no real API calls, no API key needed. + +### TestConfig (6 tests) +- Load config from file +- Save config creates directory and sets permissions +- API key priority: CLI arg > env var > config file +- Handle missing/corrupt config file +- Make auth token with and without Bearer prefix +- Require API key raises on missing + +### TestCreateTask (8 tests) +- Create slide task with all parameters +- Create doc task with minimal parameters +- Reject invalid operation type +- Include file tokens in request body +- Include style in prompt +- Handle HTTP error response +- Handle API error (success=false) +- Create task saves local record + +### TestQueryTask (5 tests) +- Query returns full task dict +- Updates local record on query +- Handle HTTP error +- Parse completed task with output +- Parse failed task with error + +### TestPollTask (8 tests) +- Poll until completed +- Poll timeout raises TimeoutError +- Poll failed task raises RuntimeError +- Progress callback called on changes +- Multiple poll cycles before completion +- Handle transient query failures +- Respect custom interval and max_time +- Poll updates local record on completion + +### TestSession (10 tests) +- Record command to history +- Undo returns last entry +- Redo returns undone entry +- Undo clears redo stack on new record +- Empty undo returns None +- Empty redo returns None +- History limit parameter +- Session status reports counts +- Save and load session file +- Handle corrupt session file + +### TestExportVerify (8 tests) +- Verify valid PPTX (ZIP with Content_Types) +- Verify valid DOCX (OOXML) +- Verify valid PDF (magic bytes) +- Verify valid PNG (header) +- Verify valid SVG (xml tag) +- Reject empty file +- Reject missing file +- Reject corrupt ZIP + +## E2E Tests (`test_full_e2e.py`) + +Require `ANYGEN_API_KEY` environment variable set with a valid key. + +### TestSlideWorkflow (6 tests) +- Create slide task returns task_id +- Poll slide task reaches completed +- Download PPTX file exists and is valid OOXML ZIP +- Full run workflow produces local file +- File size is reasonable (> 1KB) +- Task URL is accessible + +### TestDocWorkflow (6 tests) +- Create doc task returns task_id +- Poll doc task reaches completed +- Download DOCX file exists and is valid OOXML ZIP +- Full run workflow produces local file +- File size is reasonable (> 1KB) +- Task URL is accessible + +### TestCLISubprocess (6 tests) +- `--help` exits 0 +- `--json config path` returns valid JSON +- `--json task list` returns JSON array +- `task create --operation slide --prompt "..."` succeeds +- `task status ` returns status +- Full workflow via subprocess produces file + +## Realistic Workflow Scenarios + +### Scenario 1: Quarterly Business Review Deck +- **Simulates**: Executive creating a slide deck from data +- **Operations**: upload file → prepare (multi-turn) → create slide → poll → download +- **Verified**: PPTX file valid, size > 0, is real OOXML ZIP + +### Scenario 2: Technical Design Document +- **Simulates**: Engineer generating a design doc +- **Operations**: create doc → poll → download → verify DOCX +- **Verified**: DOCX is valid ZIP/OOXML, has Content_Types.xml + +### Scenario 3: Architecture Diagram +- **Simulates**: Drawing a system architecture diagram +- **Operations**: create smart_draw → poll → download drawio/excalidraw +- **Verified**: Output is valid XML (drawio) or JSON (excalidraw) diff --git a/anygen/agent-harness/cli_anything/anygen/tests/__init__.py b/anygen/agent-harness/cli_anything/anygen/tests/__init__.py new file mode 100644 index 0000000..76a70c0 --- /dev/null +++ b/anygen/agent-harness/cli_anything/anygen/tests/__init__.py @@ -0,0 +1 @@ +"""AnyGen CLI tests.""" diff --git a/anygen/agent-harness/cli_anything/anygen/tests/test_core.py b/anygen/agent-harness/cli_anything/anygen/tests/test_core.py new file mode 100644 index 0000000..0704ccd --- /dev/null +++ b/anygen/agent-harness/cli_anything/anygen/tests/test_core.py @@ -0,0 +1,351 @@ +"""Unit tests for AnyGen CLI — mocked HTTP, no API key needed.""" + +import json +import os +import sys +import tempfile +import zipfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from cli_anything.anygen.utils.anygen_backend import ( + get_api_key, + load_config, + save_config, + _make_auth_token, + _require_api_key, + VALID_OPERATIONS, +) +from cli_anything.anygen.core.session import Session, HistoryEntry +from cli_anything.anygen.core.export import verify_file + + +# ── TestConfig ──────────────────────────────────────────────────── + +class TestConfig: + def test_load_config_missing_file(self, tmp_path): + with patch("cli_anything.anygen.utils.anygen_backend.CONFIG_FILE", tmp_path / "nope.json"): + assert load_config() == {} + + def test_save_and_load_config(self, tmp_path): + cfg_file = tmp_path / "cfg" / "config.json" + with patch("cli_anything.anygen.utils.anygen_backend.CONFIG_DIR", tmp_path / "cfg"), \ + patch("cli_anything.anygen.utils.anygen_backend.CONFIG_FILE", cfg_file): + save_config({"api_key": "sk-test123"}) + assert cfg_file.exists() + result = load_config() + assert result["api_key"] == "sk-test123" + + def test_api_key_priority_cli_arg(self): + assert get_api_key("sk-cli") == "sk-cli" + + def test_api_key_priority_env(self, monkeypatch): + monkeypatch.setenv("ANYGEN_API_KEY", "sk-env") + assert get_api_key(None) == "sk-env" + + def test_make_auth_token_bare(self): + assert _make_auth_token("sk-test") == "Bearer sk-test" + + def test_make_auth_token_already_bearer(self): + assert _make_auth_token("Bearer sk-test") == "Bearer sk-test" + + def test_require_api_key_raises(self): + with pytest.raises(RuntimeError, match="API key not found"): + _require_api_key(None) + + def test_require_api_key_returns(self): + assert _require_api_key("sk-ok") == "sk-ok" + + +# ── TestCreateTask ──────────────────────────────────────────────── + +class TestCreateTask: + def _mock_response(self, status_code=200, json_data=None): + resp = MagicMock() + resp.status_code = status_code + resp.json.return_value = json_data or {} + resp.text = json.dumps(json_data or {}) + return resp + + @patch("cli_anything.anygen.utils.anygen_backend.requests.post") + def test_create_slide_task(self, mock_post): + mock_post.return_value = self._mock_response(200, { + "success": True, "task_id": "task_001", "task_url": "https://anygen.io/task/001" + }) + from cli_anything.anygen.utils.anygen_backend import create_task + result = create_task("sk-test", "slide", "Make a presentation", + language="en-US", slide_count=10) + assert result["task_id"] == "task_001" + body = mock_post.call_args[1]["json"] + assert body["operation"] == "slide" + assert body["slide_count"] == 10 + + @patch("cli_anything.anygen.utils.anygen_backend.requests.post") + def test_create_doc_minimal(self, mock_post): + mock_post.return_value = self._mock_response(200, { + "success": True, "task_id": "task_002" + }) + from cli_anything.anygen.utils.anygen_backend import create_task + result = create_task("sk-test", "doc", "Write a report") + assert result["task_id"] == "task_002" + + def test_create_invalid_operation(self): + from cli_anything.anygen.utils.anygen_backend import create_task + with pytest.raises(ValueError, match="Invalid operation"): + create_task("sk-test", "invalid_op", "test") + + @patch("cli_anything.anygen.utils.anygen_backend.requests.post") + def test_create_with_file_tokens(self, mock_post): + mock_post.return_value = self._mock_response(200, { + "success": True, "task_id": "task_003" + }) + from cli_anything.anygen.utils.anygen_backend import create_task + create_task("sk-test", "slide", "test", file_tokens=["tk_a", "tk_b"]) + body = mock_post.call_args[1]["json"] + assert body["file_tokens"] == ["tk_a", "tk_b"] + + @patch("cli_anything.anygen.utils.anygen_backend.requests.post") + def test_create_with_style(self, mock_post): + mock_post.return_value = self._mock_response(200, { + "success": True, "task_id": "task_004" + }) + from cli_anything.anygen.utils.anygen_backend import create_task + create_task("sk-test", "slide", "test prompt", style="business formal") + body = mock_post.call_args[1]["json"] + assert "Style requirement: business formal" in body["prompt"] + + @patch("cli_anything.anygen.utils.anygen_backend.requests.post") + def test_create_http_error(self, mock_post): + mock_post.return_value = self._mock_response(500, {}) + from cli_anything.anygen.utils.anygen_backend import create_task + with pytest.raises(RuntimeError, match="HTTP 500"): + create_task("sk-test", "slide", "test") + + @patch("cli_anything.anygen.utils.anygen_backend.requests.post") + def test_create_api_error(self, mock_post): + mock_post.return_value = self._mock_response(200, { + "success": False, "error": "quota exceeded" + }) + from cli_anything.anygen.utils.anygen_backend import create_task + with pytest.raises(RuntimeError, match="quota exceeded"): + create_task("sk-test", "slide", "test") + + +# ── TestQueryTask ───────────────────────────────────────────────── + +class TestQueryTask: + @patch("cli_anything.anygen.utils.anygen_backend.requests.get") + def test_query_returns_dict(self, mock_get): + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = {"status": "running", "progress": 42} + mock_get.return_value = resp + from cli_anything.anygen.utils.anygen_backend import query_task + result = query_task("sk-test", "task_001") + assert result["status"] == "running" + assert result["progress"] == 42 + + @patch("cli_anything.anygen.utils.anygen_backend.requests.get") + def test_query_completed_with_output(self, mock_get): + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = { + "status": "completed", "progress": 100, + "output": {"file_url": "https://dl.example.com/f.pptx", "file_name": "f.pptx"} + } + mock_get.return_value = resp + from cli_anything.anygen.utils.anygen_backend import query_task + result = query_task("sk-test", "task_001") + assert result["output"]["file_name"] == "f.pptx" + + @patch("cli_anything.anygen.utils.anygen_backend.requests.get") + def test_query_http_error(self, mock_get): + resp = MagicMock() + resp.status_code = 404 + resp.text = "Not found" + mock_get.return_value = resp + from cli_anything.anygen.utils.anygen_backend import query_task + with pytest.raises(RuntimeError, match="HTTP 404"): + query_task("sk-test", "task_bad") + + +# ── TestPollTask ────────────────────────────────────────────────── + +class TestPollTask: + @patch("cli_anything.anygen.utils.anygen_backend.time.sleep") + @patch("cli_anything.anygen.utils.anygen_backend.query_task") + def test_poll_until_completed(self, mock_query, mock_sleep): + mock_query.side_effect = [ + {"status": "running", "progress": 30}, + {"status": "running", "progress": 70}, + {"status": "completed", "progress": 100, "output": {}}, + ] + from cli_anything.anygen.utils.anygen_backend import poll_task + result = poll_task("sk-test", "task_001") + assert result["status"] == "completed" + assert mock_sleep.call_count == 2 + + @patch("cli_anything.anygen.utils.anygen_backend.time.sleep") + @patch("cli_anything.anygen.utils.anygen_backend.query_task") + def test_poll_failed_raises(self, mock_query, mock_sleep): + mock_query.return_value = {"status": "failed", "error": "server error"} + from cli_anything.anygen.utils.anygen_backend import poll_task + with pytest.raises(RuntimeError, match="failed"): + poll_task("sk-test", "task_001") + + @patch("cli_anything.anygen.utils.anygen_backend.time.time") + @patch("cli_anything.anygen.utils.anygen_backend.time.sleep") + @patch("cli_anything.anygen.utils.anygen_backend.query_task") + def test_poll_timeout(self, mock_query, mock_sleep, mock_time): + mock_time.side_effect = [0, 0, 9999] + mock_query.return_value = {"status": "running", "progress": 10} + from cli_anything.anygen.utils.anygen_backend import poll_task + with pytest.raises(TimeoutError, match="timeout"): + poll_task("sk-test", "task_001", max_time=5) + + @patch("cli_anything.anygen.utils.anygen_backend.time.sleep") + @patch("cli_anything.anygen.utils.anygen_backend.query_task") + def test_poll_progress_callback(self, mock_query, mock_sleep): + mock_query.side_effect = [ + {"status": "running", "progress": 50}, + {"status": "completed", "progress": 100, "output": {}}, + ] + cb = MagicMock() + from cli_anything.anygen.utils.anygen_backend import poll_task + poll_task("sk-test", "task_001", on_progress=cb) + assert cb.call_count == 2 + cb.assert_any_call("running", 50) + cb.assert_any_call("completed", 100) + + +# ── TestSession ─────────────────────────────────────────────────── + +class TestSession: + def test_record_and_history(self): + sess = Session() + sess.record("task create", {"op": "slide"}, {"task_id": "t1"}) + sess.record("task poll", {"id": "t1"}) + h = sess.history() + assert len(h) == 2 + assert h[0]["command"] == "task create" + + def test_undo(self): + sess = Session() + sess.record("cmd1", {}) + sess.record("cmd2", {}) + entry = sess.undo() + assert entry.command == "cmd2" + assert sess.history_count == 1 + + def test_redo(self): + sess = Session() + sess.record("cmd1", {}) + sess.undo() + entry = sess.redo() + assert entry.command == "cmd1" + assert sess.history_count == 1 + + def test_undo_clears_redo_on_record(self): + sess = Session() + sess.record("cmd1", {}) + sess.undo() + sess.record("cmd2", {}) + assert not sess.can_redo + + def test_undo_empty(self): + sess = Session() + assert sess.undo() is None + + def test_redo_empty(self): + sess = Session() + assert sess.redo() is None + + def test_history_limit(self): + sess = Session() + for i in range(30): + sess.record(f"cmd{i}", {}) + assert len(sess.history(limit=5)) == 5 + + def test_status(self): + sess = Session() + sess.record("cmd1", {}) + st = sess.status() + assert st["history_count"] == 1 + assert st["can_undo"] is True + assert st["can_redo"] is False + + def test_save_and_load(self, tmp_path): + path = str(tmp_path / "session.json") + sess = Session() + sess.record("cmd1", {"a": 1}, {"r": "ok"}) + sess.save(path) + + sess2 = Session(session_file=path) + h = sess2.history() + assert len(h) == 1 + assert h[0]["command"] == "cmd1" + + def test_load_corrupt_file(self, tmp_path): + path = tmp_path / "bad.json" + path.write_text("not json") + sess = Session(session_file=str(path)) + assert sess.history_count == 0 + + +# ── TestExportVerify ────────────────────────────────────────────── + +class TestExportVerify: + def test_verify_missing_file(self): + r = verify_file("/nonexistent/file.pptx") + assert not r["valid"] + + def test_verify_empty_file(self, tmp_path): + f = tmp_path / "empty.pptx" + f.write_bytes(b"") + r = verify_file(str(f)) + assert not r["valid"] + + def test_verify_valid_pptx(self, tmp_path): + f = tmp_path / "test.pptx" + with zipfile.ZipFile(f, "w") as zf: + zf.writestr("[Content_Types].xml", '') + zf.writestr("ppt/presentation.xml", "

") + r = verify_file(str(f)) + assert r["valid"] + assert r["format"] == "OOXML" + + def test_verify_valid_docx(self, tmp_path): + f = tmp_path / "test.docx" + with zipfile.ZipFile(f, "w") as zf: + zf.writestr("[Content_Types].xml", '') + zf.writestr("word/document.xml", "") + r = verify_file(str(f)) + assert r["valid"] + + def test_verify_valid_pdf(self, tmp_path): + f = tmp_path / "test.pdf" + f.write_bytes(b"%PDF-1.4 fake pdf content") + r = verify_file(str(f)) + assert r["valid"] + assert r["format"] == "PDF" + + def test_verify_valid_png(self, tmp_path): + f = tmp_path / "test.png" + f.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100) + r = verify_file(str(f)) + assert r["valid"] + assert r["format"] == "PNG" + + def test_verify_valid_svg(self, tmp_path): + f = tmp_path / "test.svg" + f.write_text('') + r = verify_file(str(f)) + assert r["valid"] + + def test_verify_corrupt_zip(self, tmp_path): + f = tmp_path / "bad.pptx" + f.write_bytes(b"PK\x03\x04" + b"\x00" * 50) + r = verify_file(str(f)) + assert not r["valid"] diff --git a/anygen/agent-harness/cli_anything/anygen/tests/test_full_e2e.py b/anygen/agent-harness/cli_anything/anygen/tests/test_full_e2e.py new file mode 100644 index 0000000..998d131 --- /dev/null +++ b/anygen/agent-harness/cli_anything/anygen/tests/test_full_e2e.py @@ -0,0 +1,170 @@ +"""E2E tests for AnyGen CLI — require ANYGEN_API_KEY for real API calls. + +Run with: + ANYGEN_API_KEY=sk-xxx python3 -m pytest cli_anything/anygen/tests/test_full_e2e.py -v -s + CLI_ANYTHING_FORCE_INSTALLED=1 ANYGEN_API_KEY=sk-xxx python3 -m pytest ... -v -s +""" + +import json +import os +import subprocess +import sys +import tempfile +import zipfile +from pathlib import Path + +import pytest + +from cli_anything.anygen.utils.anygen_backend import get_api_key, DOWNLOADABLE_OPERATIONS +from cli_anything.anygen.core.task import create_task, poll_task, download_file, run_full_workflow +from cli_anything.anygen.core.export import verify_file + + +API_KEY = get_api_key() +SKIP_REASON = "ANYGEN_API_KEY not set — skipping E2E tests" +requires_api_key = pytest.mark.skipif(not API_KEY, reason=SKIP_REASON) + + +def _resolve_cli(name): + """Resolve installed CLI command; falls back to python -m for dev.""" + import shutil + force = os.environ.get("CLI_ANYTHING_FORCE_INSTALLED", "").strip() == "1" + path = shutil.which(name) + if path: + print(f"[_resolve_cli] Using installed command: {path}") + return [path] + if force: + raise RuntimeError(f"{name} not found in PATH. Install with: pip install -e .") + module = "cli_anything.anygen.anygen_cli" + print(f"[_resolve_cli] Falling back to: {sys.executable} -m {module}") + return [sys.executable, "-m", module] + + +# ── Slide Workflow ──────────────────────────────────────────────── + +@requires_api_key +class TestSlideWorkflow: + def test_create_slide_task(self): + result = create_task(API_KEY, "slide", "Create a 3-slide demo about Python") + assert "task_id" in result + assert result["task_id"].startswith("task_") or len(result["task_id"]) > 0 + print(f"\n Task ID: {result['task_id']}") + if result.get("task_url"): + print(f" Task URL: {result['task_url']}") + + def test_full_slide_workflow(self, tmp_path): + result = run_full_workflow( + API_KEY, "slide", + "Create a brief 3-slide presentation about CLI tools", + output_dir=str(tmp_path), + slide_count=3, + ) + assert result["status"] == "completed" + assert result.get("local_path") + + local = result["local_path"] + assert os.path.exists(local) + size = os.path.getsize(local) + assert size > 1000, f"File suspiciously small: {size} bytes" + + v = verify_file(local) + assert v["valid"], f"File verification failed: {v['details']}" + print(f"\n PPTX: {local} ({size:,} bytes)") + print(f" Format: {v['format']} — {v['details']}") + + +# ── Doc Workflow ────────────────────────────────────────────────── + +@requires_api_key +class TestDocWorkflow: + def test_create_doc_task(self): + result = create_task(API_KEY, "doc", "Write a one-page summary about REST APIs") + assert "task_id" in result + print(f"\n Task ID: {result['task_id']}") + + def test_full_doc_workflow(self, tmp_path): + result = run_full_workflow( + API_KEY, "doc", + "Write a brief technical note about HTTP status codes", + output_dir=str(tmp_path), + ) + assert result["status"] == "completed" + assert result.get("local_path") + + local = result["local_path"] + assert os.path.exists(local) + size = os.path.getsize(local) + assert size > 1000, f"File suspiciously small: {size} bytes" + + v = verify_file(local) + assert v["valid"], f"File verification failed: {v['details']}" + print(f"\n DOCX: {local} ({size:,} bytes)") + print(f" Format: {v['format']} — {v['details']}") + + +# ── CLI Subprocess ──────────────────────────────────────────────── + +class TestCLISubprocess: + CLI_BASE = _resolve_cli("cli-anything-anygen") + + def _run(self, args, check=True): + return subprocess.run( + self.CLI_BASE + args, + capture_output=True, text=True, + check=check, + ) + + def test_help(self): + result = self._run(["--help"]) + assert result.returncode == 0 + assert "AnyGen" in result.stdout + + def test_json_config_path(self): + result = self._run(["--json", "config", "path"]) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert "path" in data + + def test_json_task_list(self): + result = self._run(["--json", "task", "list"]) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert isinstance(data, list) + + def test_session_status(self): + result = self._run(["--json", "session", "status"]) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert "history_count" in data + + @requires_api_key + def test_create_slide_subprocess(self): + result = self._run([ + "--json", "--api-key", API_KEY, + "task", "create", + "--operation", "slide", + "--prompt", "Brief demo about testing", + ]) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert "task_id" in data + print(f"\n Subprocess task ID: {data['task_id']}") + + @requires_api_key + def test_full_workflow_subprocess(self, tmp_path): + result = self._run([ + "--json", "--api-key", API_KEY, + "task", "run", + "--operation", "slide", + "--prompt", "A 3-slide overview of Python testing", + "--output", str(tmp_path), + "--slide-count", "3", + ]) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert data.get("local_path") or data.get("task_url") + if data.get("local_path"): + assert os.path.exists(data["local_path"]) + v = verify_file(data["local_path"]) + print(f"\n Subprocess file: {data['local_path']}") + print(f" Valid: {v['valid']} — {v['details']}") diff --git a/anygen/agent-harness/cli_anything/anygen/utils/__init__.py b/anygen/agent-harness/cli_anything/anygen/utils/__init__.py new file mode 100644 index 0000000..73c6aab --- /dev/null +++ b/anygen/agent-harness/cli_anything/anygen/utils/__init__.py @@ -0,0 +1 @@ +"""AnyGen CLI utilities.""" diff --git a/anygen/agent-harness/cli_anything/anygen/utils/anygen_backend.py b/anygen/agent-harness/cli_anything/anygen/utils/anygen_backend.py new file mode 100644 index 0000000..77cc508 --- /dev/null +++ b/anygen/agent-harness/cli_anything/anygen/utils/anygen_backend.py @@ -0,0 +1,442 @@ +"""AnyGen API backend — wraps the AnyGen OpenAPI for task lifecycle management. + +This module handles all HTTP communication with the AnyGen cloud service: +create tasks, poll status, upload files, download results. +""" + +from __future__ import annotations + +import json +import os +import sys +import time +import base64 +from datetime import datetime +from pathlib import Path +from typing import Callable + +try: + import requests +except ImportError: + print( + "requests library not found. Install with: pip3 install requests", + file=sys.stderr, + ) + sys.exit(1) + +API_BASE = "https://www.anygen.io" +POLL_INTERVAL = 3 +MAX_POLL_TIME = 1200 # 20 minutes +CONFIG_DIR = Path.home() / ".config" / "anygen" +CONFIG_FILE = CONFIG_DIR / "config.json" +ENV_API_KEY = "ANYGEN_API_KEY" + +VALID_OPERATIONS = [ + "chat", "slide", "doc", "storybook", + "data_analysis", "website", "smart_draw", +] + +DOWNLOADABLE_OPERATIONS = {"slide", "doc", "smart_draw"} + + +# ── Config ──────────────────────────────────────────────────────────── + +def load_config() -> dict: + """Load configuration from ~/.config/anygen/config.json.""" + if not CONFIG_FILE.exists(): + return {} + try: + with open(CONFIG_FILE, "r") as f: + return json.load(f) + except (json.JSONDecodeError, IOError): + return {} + + +def save_config(config: dict): + """Save configuration to ~/.config/anygen/config.json (mode 600).""" + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + with open(CONFIG_FILE, "w") as f: + json.dump(config, f, indent=2) + CONFIG_FILE.chmod(0o600) + + +def get_api_key(cli_key: str | None = None) -> str | None: + """Resolve API key: CLI arg → env var → config file.""" + if cli_key: + return cli_key + env_key = os.environ.get(ENV_API_KEY) + if env_key: + return env_key + return load_config().get("api_key") + + +def _make_auth_token(api_key: str) -> str: + return api_key if api_key.startswith("Bearer ") else f"Bearer {api_key}" + + +def _require_api_key(api_key: str | None) -> str: + if not api_key: + raise RuntimeError( + "AnyGen API key not found. Provide one via:\n" + " 1. --api-key sk-xxx\n" + f" 2. export {ENV_API_KEY}=sk-xxx\n" + " 3. cli-anything-anygen config set api_key sk-xxx\n" + "Get a key at https://www.anygen.io/home → Setting → Integration" + ) + return api_key + + +# ── File Upload ─────────────────────────────────────────────────────── + +def upload_file(api_key: str, file_path: str, extra_headers: dict | None = None) -> dict: + """Upload a file and return {"file_token": ..., "filename": ..., "file_size": ...}.""" + api_key = _require_api_key(api_key) + path = Path(file_path) + if not path.exists(): + raise FileNotFoundError(f"File not found: {file_path}") + + headers = {"Authorization": _make_auth_token(api_key)} + if extra_headers: + headers.update(extra_headers) + + with open(path, "rb") as f: + files = {"file": (path.name, f)} + data = {"filename": path.name} + resp = requests.post( + f"{API_BASE}/v1/openapi/files/upload", + files=files, data=data, headers=headers, timeout=60, + ) + + if resp.status_code != 200: + raise RuntimeError(f"Upload failed (HTTP {resp.status_code}): {resp.text[:300]}") + + result = resp.json() + if not result.get("success"): + raise RuntimeError(f"Upload failed: {result.get('error', 'Unknown error')}") + + return { + "file_token": result.get("file_token"), + "filename": result.get("filename"), + "file_size": result.get("file_size"), + } + + +# ── Encode file (legacy base64) ────────────────────────────────────── + +def encode_file(file_path: str) -> dict: + """Encode a file to base64 for legacy attachment in create_task.""" + path = Path(file_path) + if not path.exists(): + raise FileNotFoundError(f"File not found: {file_path}") + + with open(path, "rb") as f: + content = f.read() + + mime_types = { + ".pdf": "application/pdf", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".txt": "text/plain", + ".doc": "application/msword", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".ppt": "application/vnd.ms-powerpoint", + ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", + } + mime_type = mime_types.get(path.suffix.lower(), "application/octet-stream") + + return { + "file_name": path.name, + "file_type": mime_type, + "file_data": base64.b64encode(content).decode("utf-8"), + } + + +# ── Prepare (multi-turn) ───────────────────────────────────────────── + +def prepare_task( + api_key: str, + messages: list[dict], + file_tokens: list[str] | None = None, + extra_headers: dict | None = None, +) -> dict: + """Call the prepare API for multi-turn requirement analysis. + + Returns the full response dict including 'reply', 'status', + 'suggested_task_params', and 'messages'. + """ + api_key = _require_api_key(api_key) + auth_token = _make_auth_token(api_key) + + body: dict = {"auth_token": auth_token, "messages": messages} + if file_tokens: + body["file_tokens"] = file_tokens + + headers: dict = {"Content-Type": "application/json"} + if extra_headers: + headers.update(extra_headers) + + resp = requests.post( + f"{API_BASE}/v1/openapi/tasks/prepare", + json=body, headers=headers, timeout=120, + ) + if resp.status_code != 200: + raise RuntimeError(f"Prepare failed (HTTP {resp.status_code}): {resp.text[:300]}") + + result = resp.json() + if not result.get("success"): + raise RuntimeError(f"Prepare failed: {result.get('error', 'Unknown error')}") + return result + + +# ── Create Task ─────────────────────────────────────────────────────── + +def create_task( + api_key: str, + operation: str, + prompt: str, + *, + language: str | None = None, + slide_count: int | None = None, + template: str | None = None, + ratio: str | None = None, + export_format: str | None = None, + file_tokens: list[str] | None = None, + files: list[str] | None = None, + style: str | None = None, + extra_headers: dict | None = None, +) -> dict: + """Create an async generation task. + + Returns {"task_id": ..., "task_url": ...}. + """ + api_key = _require_api_key(api_key) + if operation not in VALID_OPERATIONS: + raise ValueError( + f"Invalid operation '{operation}'. " + f"Valid: {', '.join(VALID_OPERATIONS)}" + ) + + final_prompt = prompt + if style: + final_prompt = f"{prompt}\n\nStyle requirement: {style}" + + body: dict = { + "auth_token": _make_auth_token(api_key), + "operation": operation, + "prompt": final_prompt, + } + if language: + body["language"] = language + if operation == "slide": + if slide_count: + body["slide_count"] = slide_count + if template: + body["template"] = template + if ratio: + body["ratio"] = ratio + if export_format: + body["export_format"] = export_format + if file_tokens: + body["file_tokens"] = file_tokens + + if files: + encoded = [] + for fp in files: + encoded.append(encode_file(fp)) + body["files"] = encoded + + headers: dict = {"Content-Type": "application/json"} + if extra_headers: + headers.update(extra_headers) + + resp = requests.post( + f"{API_BASE}/v1/openapi/tasks", + json=body, headers=headers, timeout=30, + ) + if resp.status_code != 200: + raise RuntimeError(f"Create task failed (HTTP {resp.status_code}): {resp.text[:300]}") + + result = resp.json() + if not result.get("success"): + raise RuntimeError(f"Task creation failed: {result.get('error', 'Unknown error')}") + + return { + "task_id": result.get("task_id"), + "task_url": result.get("task_url"), + } + + +# ── Query Task ──────────────────────────────────────────────────────── + +def query_task(api_key: str, task_id: str, extra_headers: dict | None = None) -> dict: + """Single non-blocking query of task status. Returns full task dict.""" + api_key = _require_api_key(api_key) + headers = {"Authorization": _make_auth_token(api_key)} + if extra_headers: + headers.update(extra_headers) + + resp = requests.get( + f"{API_BASE}/v1/openapi/tasks/{task_id}", + headers=headers, timeout=30, + ) + if resp.status_code != 200: + raise RuntimeError(f"Query failed (HTTP {resp.status_code}): {resp.text[:300]}") + return resp.json() + + +# ── Poll Task ───────────────────────────────────────────────────────── + +def poll_task( + api_key: str, + task_id: str, + *, + max_time: int = MAX_POLL_TIME, + interval: int = POLL_INTERVAL, + extra_headers: dict | None = None, + on_progress: Callable | None = None, +) -> dict: + """Poll task until completed/failed. Returns final task dict. + + Args: + on_progress: optional callback(status, progress_pct) called on each poll. + """ + api_key = _require_api_key(api_key) + start = time.time() + last_progress = -1 + + while True: + elapsed = time.time() - start + if elapsed > max_time: + raise TimeoutError(f"Polling timeout after {max_time}s for task {task_id}") + + task = query_task(api_key, task_id, extra_headers) + status = task.get("status") + progress = task.get("progress", 0) + + if progress != last_progress and on_progress: + on_progress(status, progress) + last_progress = progress + + if status == "completed": + return task + if status == "failed": + error = task.get("error", "Unknown error") + raise RuntimeError(f"Task {task_id} failed: {error}") + + time.sleep(interval) + + +# ── Download ────────────────────────────────────────────────────────── + +def download_file( + api_key: str, + task_id: str, + output_dir: str, + extra_headers: dict | None = None, +) -> dict: + """Download the generated file for a completed task. + + Returns {"local_path": ..., "file_name": ..., "file_size": ..., "task_url": ...}. + """ + task = query_task(api_key, task_id, extra_headers) + if task.get("status") != "completed": + raise RuntimeError(f"Task not completed (status={task.get('status')})") + + output = task.get("output", {}) + file_url = output.get("file_url") + file_name = output.get("file_name") + task_url = output.get("task_url", f"{API_BASE}/task/{task_id}") + + if not file_url: + raise RuntimeError("No download URL available for this task") + + resp = requests.get(file_url, timeout=120) + resp.raise_for_status() + + out_path = Path(output_dir) + out_path.mkdir(parents=True, exist_ok=True) + file_path = out_path / (file_name or "output") + + if file_path.exists(): + stem, suffix = file_path.stem, file_path.suffix + counter = 1 + while file_path.exists(): + file_path = out_path / f"{stem}_{counter}{suffix}" + counter += 1 + + with open(file_path, "wb") as f: + f.write(resp.content) + + return { + "local_path": str(file_path), + "file_name": file_name, + "file_size": len(resp.content), + "task_url": task_url, + } + + +def download_thumbnail( + api_key: str, + task_id: str, + output_dir: str, + extra_headers: dict | None = None, +) -> dict: + """Download only the thumbnail image for a completed task.""" + task = query_task(api_key, task_id, extra_headers) + if task.get("status") != "completed": + raise RuntimeError(f"Task not completed (status={task.get('status')})") + + output = task.get("output", {}) + thumbnail_url = output.get("thumbnail_url") + if not thumbnail_url: + raise RuntimeError("No thumbnail available for this task") + + resp = requests.get(thumbnail_url, timeout=120) + resp.raise_for_status() + + out_path = Path(output_dir) + out_path.mkdir(parents=True, exist_ok=True) + file_path = out_path / f"thumbnail_{task_id}.png" + + with open(file_path, "wb") as f: + f.write(resp.content) + + return { + "local_path": str(file_path), + "file_size": len(resp.content), + } + + +# ── Full Workflow ───────────────────────────────────────────────────── + +def run_full_workflow( + api_key: str, + operation: str, + prompt: str, + output_dir: str | None = None, + *, + on_progress: Callable | None = None, + **create_kwargs, +) -> dict: + """Full workflow: create → poll → download. + + Returns dict with task info and local_path (if output_dir given). + """ + result = create_task(api_key, operation, prompt, **create_kwargs) + task_id = result["task_id"] + + task = poll_task(api_key, task_id, on_progress=on_progress) + + dl_info = {} + if output_dir and operation in DOWNLOADABLE_OPERATIONS: + dl_info = download_file(api_key, task_id, output_dir) + + return { + "task_id": task_id, + "task_url": result.get("task_url"), + "status": task.get("status"), + "output": task.get("output", {}), + **dl_info, + } diff --git a/anygen/agent-harness/cli_anything/anygen/utils/repl_skin.py b/anygen/agent-harness/cli_anything/anygen/utils/repl_skin.py new file mode 100644 index 0000000..bc1fb6d --- /dev/null +++ b/anygen/agent-harness/cli_anything/anygen/utils/repl_skin.py @@ -0,0 +1,567 @@ +"""cli-anything REPL Skin — Unified terminal interface for all CLI harnesses. + +Copy this file into your CLI package at: + cli_anything//utils/repl_skin.py + +Usage: + from cli_anything..utils.repl_skin import ReplSkin + + skin = ReplSkin("shotcut", version="1.0.0") + skin.print_banner() # auto-detects repo-root or packaged SKILL.md + prompt_text = skin.prompt(project_name="my_video.mlt", modified=True) + skin.success("Project saved") + skin.error("File not found") + skin.warning("Unsaved changes") + skin.info("Processing 24 clips...") + skin.status("Track 1", "3 clips, 00:02:30") + skin.table(headers, rows) + skin.print_goodbye() +""" + +import os +import sys +from pathlib import Path + +# ── ANSI color codes (no external deps for core styling) ────────────── + +_RESET = "\033[0m" +_BOLD = "\033[1m" +_DIM = "\033[2m" +_ITALIC = "\033[3m" +_UNDERLINE = "\033[4m" + +# Brand colors +_CYAN = "\033[38;5;80m" # cli-anything brand cyan +_CYAN_BG = "\033[48;5;80m" +_WHITE = "\033[97m" +_GRAY = "\033[38;5;245m" +_DARK_GRAY = "\033[38;5;240m" +_LIGHT_GRAY = "\033[38;5;250m" + +# Software accent colors — each software gets a unique accent +_ACCENT_COLORS = { + "gimp": "\033[38;5;214m", # warm orange + "blender": "\033[38;5;208m", # deep orange + "inkscape": "\033[38;5;39m", # bright blue + "audacity": "\033[38;5;33m", # navy blue + "libreoffice": "\033[38;5;40m", # green + "obs_studio": "\033[38;5;55m", # purple + "kdenlive": "\033[38;5;69m", # slate blue + "shotcut": "\033[38;5;35m", # teal green +} +_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue + +# Status colors +_GREEN = "\033[38;5;78m" +_YELLOW = "\033[38;5;220m" +_RED = "\033[38;5;196m" +_BLUE = "\033[38;5;75m" +_MAGENTA = "\033[38;5;176m" + +_SKILL_SOURCE_REPO = os.environ.get("CLI_ANYTHING_SKILL_REPO", "HKUDS/CLI-Anything") + +# ── Brand icon ──────────────────────────────────────────────────────── + +# The cli-anything icon: a small colored diamond/chevron mark +_ICON = f"{_CYAN}{_BOLD}◆{_RESET}" +_ICON_SMALL = f"{_CYAN}▸{_RESET}" + +# ── Box drawing characters ──────────────────────────────────────────── + +_H_LINE = "─" +_V_LINE = "│" +_TL = "╭" +_TR = "╮" +_BL = "╰" +_BR = "╯" +_T_DOWN = "┬" +_T_UP = "┴" +_T_RIGHT = "├" +_T_LEFT = "┤" +_CROSS = "┼" + + +def _strip_ansi(text: str) -> str: + """Remove ANSI escape codes for length calculation.""" + import re + return re.sub(r"\033\[[^m]*m", "", text) + + +def _visible_len(text: str) -> int: + """Get visible length of text (excluding ANSI codes).""" + return len(_strip_ansi(text)) + + +def _display_home_path(path: str) -> str: + """Display a path relative to the home directory when possible.""" + expanded = Path(path).expanduser().resolve() + home = Path.home().resolve() + try: + relative = expanded.relative_to(home) + return f"~/{relative.as_posix()}" + except ValueError: + return str(expanded) + + +class ReplSkin: + """Unified REPL skin for cli-anything CLIs. + + Provides consistent branding, prompts, and message formatting + across all CLI harnesses built with the cli-anything methodology. + """ + + def __init__(self, software: str, version: str = "1.0.0", + history_file: str | None = None, skill_path: str | None = None): + """Initialize the REPL skin. + + Args: + software: Software name (e.g., "gimp", "shotcut", "blender"). + version: CLI version string. + history_file: Path for persistent command history. + Defaults to ~/.cli-anything-/history + skill_path: Path to the SKILL.md file for agent discovery. + Auto-detected from the repo-root skills/ tree when present, + otherwise from the package's skills/ directory. + Displayed in banner for AI agents to know where to read skill info. + """ + self.software = software.lower().replace("-", "_") + self.display_name = software.replace("_", " ").title() + self.version = version + software_aliases = {"iterm2_ctl": "iterm2"} + self.skill_slug = software_aliases.get(self.software, self.software).replace("_", "-") + self.skill_id = f"cli-anything-{self.skill_slug}" + self.skill_install_cmd = ( + f"npx skills add {_SKILL_SOURCE_REPO} --skill {self.skill_id} -g -y" + ) + global_skill_root = Path( + os.environ.get("CLI_ANYTHING_GLOBAL_SKILLS_DIR", str(Path.home() / ".agents" / "skills")) + ).expanduser() + self.global_skill_path = str(global_skill_root / self.skill_id / "SKILL.md") + + # Prefer repo-root canonical skills//SKILL.md when running + # inside the CLI-Anything monorepo. Fall back to the packaged + # cli_anything//skills/SKILL.md for installed harnesses. + if skill_path is None: + package_skill = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md" + repo_skill = None + for parent in Path(__file__).resolve().parents: + candidate = parent / "skills" / self.skill_id / "SKILL.md" + if candidate.is_file(): + repo_skill = candidate + break + if repo_skill and repo_skill.is_file(): + skill_path = str(repo_skill) + elif package_skill.is_file(): + skill_path = str(package_skill) + self.skill_path = skill_path + self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT) + + # History file + if history_file is None: + hist_dir = Path.home() / f".cli-anything-{self.software}" + hist_dir.mkdir(parents=True, exist_ok=True) + self.history_file = str(hist_dir / "history") + else: + self.history_file = history_file + + # Detect terminal capabilities + self._color = self._detect_color_support() + + def _detect_color_support(self) -> bool: + """Check if terminal supports color.""" + if os.environ.get("NO_COLOR"): + return False + if os.environ.get("CLI_ANYTHING_NO_COLOR"): + return False + if not hasattr(sys.stdout, "isatty"): + return False + return sys.stdout.isatty() + + def _c(self, code: str, text: str) -> str: + """Apply color code if colors are supported.""" + if not self._color: + return text + return f"{code}{text}{_RESET}" + + # ── Banner ──────────────────────────────────────────────────────── + + def print_banner(self): + """Print the startup banner with branding.""" + import textwrap + + inner = 72 + + def _box_line(content: str) -> str: + """Wrap content in box drawing, padding to inner width.""" + pad = inner - _visible_len(content) + vl = self._c(_DARK_GRAY, _V_LINE) + return f"{vl}{content}{' ' * max(0, pad)}{vl}" + + def _meta_lines(label: str, value: str) -> list[str]: + """Wrap a metadata line for the banner box.""" + icon = self._c(_MAGENTA, "◇") + label_text = self._c(_DARK_GRAY, label) + prefix = f" {icon} {label_text} " + available = max(12, inner - _visible_len(prefix)) + wrapped = textwrap.wrap( + value, + width=available, + break_long_words=True, + break_on_hyphens=False, + ) or [""] + lines = [f"{prefix}{self._c(_LIGHT_GRAY, wrapped[0])}"] + continuation_prefix = " " * _visible_len(prefix) + for chunk in wrapped[1:]: + lines.append(f"{continuation_prefix}{self._c(_LIGHT_GRAY, chunk)}") + return lines + + top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}") + bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}") + + # Title: ◆ cli-anything · Shotcut + icon = self._c(_CYAN + _BOLD, "◆") + brand = self._c(_CYAN + _BOLD, "cli-anything") + dot = self._c(_DARK_GRAY, "·") + name = self._c(self.accent + _BOLD, self.display_name) + title = f" {icon} {brand} {dot} {name}" + + ver = f" {self._c(_DARK_GRAY, f' v{self.version}')}" + tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}" + empty = "" + + meta_lines: list[str] = [] + meta_lines.extend(_meta_lines("Install:", self.skill_install_cmd)) + meta_lines.extend(_meta_lines("Global skill:", _display_home_path(self.global_skill_path))) + print(top) + print(_box_line(title)) + print(_box_line(ver)) + for line in meta_lines: + print(_box_line(line)) + print(_box_line(empty)) + print(_box_line(tip)) + print(bot) + print() + + # ── Prompt ──────────────────────────────────────────────────────── + + def prompt(self, project_name: str = "", modified: bool = False, + context: str = "") -> str: + """Build a styled prompt string for prompt_toolkit or input(). + + Args: + project_name: Current project name (empty if none open). + modified: Whether the project has unsaved changes. + context: Optional extra context to show in prompt. + + Returns: + Formatted prompt string. + """ + parts = [] + + # Icon + if self._color: + parts.append(f"{_CYAN}◆{_RESET} ") + else: + parts.append("> ") + + # Software name + parts.append(self._c(self.accent + _BOLD, self.software)) + + # Project context + if project_name or context: + ctx = context or project_name + mod = "*" if modified else "" + parts.append(f" {self._c(_DARK_GRAY, '[')}") + parts.append(self._c(_LIGHT_GRAY, f"{ctx}{mod}")) + parts.append(self._c(_DARK_GRAY, ']')) + + parts.append(self._c(_GRAY, " ❯ ")) + + return "".join(parts) + + def prompt_tokens(self, project_name: str = "", modified: bool = False, + context: str = ""): + """Build prompt_toolkit formatted text tokens for the prompt. + + Use with prompt_toolkit's FormattedText for proper ANSI handling. + + Returns: + list of (style, text) tuples for prompt_toolkit. + """ + accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff") + tokens = [] + + tokens.append(("class:icon", "◆ ")) + tokens.append(("class:software", self.software)) + + if project_name or context: + ctx = context or project_name + mod = "*" if modified else "" + tokens.append(("class:bracket", " [")) + tokens.append(("class:context", f"{ctx}{mod}")) + tokens.append(("class:bracket", "]")) + + tokens.append(("class:arrow", " ❯ ")) + + return tokens + + def get_prompt_style(self): + """Get a prompt_toolkit Style object matching the skin. + + Returns: + prompt_toolkit.styles.Style + """ + try: + from prompt_toolkit.styles import Style + except ImportError: + return None + + accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff") + + return Style.from_dict({ + "icon": "#5fdfdf bold", # cyan brand color + "software": f"{accent_hex} bold", + "bracket": "#585858", + "context": "#bcbcbc", + "arrow": "#808080", + # Completion menu + "completion-menu.completion": "bg:#303030 #bcbcbc", + "completion-menu.completion.current": f"bg:{accent_hex} #000000", + "completion-menu.meta.completion": "bg:#303030 #808080", + "completion-menu.meta.completion.current": f"bg:{accent_hex} #000000", + # Auto-suggest + "auto-suggest": "#585858", + # Bottom toolbar + "bottom-toolbar": "bg:#1c1c1c #808080", + "bottom-toolbar.text": "#808080", + }) + + # ── Messages ────────────────────────────────────────────────────── + + def success(self, message: str): + """Print a success message with green checkmark.""" + icon = self._c(_GREEN + _BOLD, "✓") + print(f" {icon} {self._c(_GREEN, message)}") + + def error(self, message: str): + """Print an error message with red cross.""" + icon = self._c(_RED + _BOLD, "✗") + print(f" {icon} {self._c(_RED, message)}", file=sys.stderr) + + def warning(self, message: str): + """Print a warning message with yellow triangle.""" + icon = self._c(_YELLOW + _BOLD, "⚠") + print(f" {icon} {self._c(_YELLOW, message)}") + + def info(self, message: str): + """Print an info message with blue dot.""" + icon = self._c(_BLUE, "●") + print(f" {icon} {self._c(_LIGHT_GRAY, message)}") + + def hint(self, message: str): + """Print a subtle hint message.""" + print(f" {self._c(_DARK_GRAY, message)}") + + def section(self, title: str): + """Print a section header.""" + print() + print(f" {self._c(self.accent + _BOLD, title)}") + print(f" {self._c(_DARK_GRAY, _H_LINE * len(title))}") + + # ── Status display ──────────────────────────────────────────────── + + def status(self, label: str, value: str): + """Print a key-value status line.""" + lbl = self._c(_GRAY, f" {label}:") + val = self._c(_WHITE, f" {value}") + print(f"{lbl}{val}") + + def status_block(self, items: dict[str, str], title: str = ""): + """Print a block of status key-value pairs. + + Args: + items: Dict of label -> value pairs. + title: Optional title for the block. + """ + if title: + self.section(title) + + max_key = max(len(k) for k in items) if items else 0 + for label, value in items.items(): + lbl = self._c(_GRAY, f" {label:<{max_key}}") + val = self._c(_WHITE, f" {value}") + print(f"{lbl}{val}") + + def progress(self, current: int, total: int, label: str = ""): + """Print a simple progress indicator. + + Args: + current: Current step number. + total: Total number of steps. + label: Optional label for the progress. + """ + pct = int(current / total * 100) if total > 0 else 0 + bar_width = 20 + filled = int(bar_width * current / total) if total > 0 else 0 + bar = "█" * filled + "░" * (bar_width - filled) + text = f" {self._c(_CYAN, bar)} {self._c(_GRAY, f'{pct:3d}%')}" + if label: + text += f" {self._c(_LIGHT_GRAY, label)}" + print(text) + + # ── Table display ───────────────────────────────────────────────── + + def table(self, headers: list[str], rows: list[list[str]], + max_col_width: int = 40): + """Print a formatted table with box-drawing characters. + + Args: + headers: Column header strings. + rows: List of rows, each a list of cell strings. + max_col_width: Maximum column width before truncation. + """ + if not headers: + return + + # Calculate column widths + col_widths = [min(len(h), max_col_width) for h in headers] + for row in rows: + for i, cell in enumerate(row): + if i < len(col_widths): + col_widths[i] = min( + max(col_widths[i], len(str(cell))), max_col_width + ) + + def pad(text: str, width: int) -> str: + t = str(text)[:width] + return t + " " * (width - len(t)) + + # Header + header_cells = [ + self._c(_CYAN + _BOLD, pad(h, col_widths[i])) + for i, h in enumerate(headers) + ] + sep = self._c(_DARK_GRAY, f" {_V_LINE} ") + header_line = f" {sep.join(header_cells)}" + print(header_line) + + # Separator + sep_parts = [self._c(_DARK_GRAY, _H_LINE * w) for w in col_widths] + sep_line = self._c(_DARK_GRAY, f" {'───'.join([_H_LINE * w for w in col_widths])}") + print(sep_line) + + # Rows + for row in rows: + cells = [] + for i, cell in enumerate(row): + if i < len(col_widths): + cells.append(self._c(_LIGHT_GRAY, pad(str(cell), col_widths[i]))) + row_sep = self._c(_DARK_GRAY, f" {_V_LINE} ") + print(f" {row_sep.join(cells)}") + + # ── Help display ────────────────────────────────────────────────── + + def help(self, commands: dict[str, str]): + """Print a formatted help listing. + + Args: + commands: Dict of command -> description pairs. + """ + self.section("Commands") + max_cmd = max(len(c) for c in commands) if commands else 0 + for cmd, desc in commands.items(): + cmd_styled = self._c(self.accent, f" {cmd:<{max_cmd}}") + desc_styled = self._c(_GRAY, f" {desc}") + print(f"{cmd_styled}{desc_styled}") + print() + + # ── Goodbye ─────────────────────────────────────────────────────── + + def print_goodbye(self): + """Print a styled goodbye message.""" + print(f"\n {_ICON_SMALL} {self._c(_GRAY, 'Goodbye!')}\n") + + # ── Prompt toolkit session factory ──────────────────────────────── + + def create_prompt_session(self): + """Create a prompt_toolkit PromptSession with skin styling. + + Returns: + A configured PromptSession, or None if prompt_toolkit unavailable. + """ + try: + from prompt_toolkit import PromptSession + from prompt_toolkit.history import FileHistory + from prompt_toolkit.auto_suggest import AutoSuggestFromHistory + from prompt_toolkit.formatted_text import FormattedText + + style = self.get_prompt_style() + + session = PromptSession( + history=FileHistory(self.history_file), + auto_suggest=AutoSuggestFromHistory(), + style=style, + enable_history_search=True, + ) + return session + except ImportError: + return None + + def get_input(self, pt_session, project_name: str = "", + modified: bool = False, context: str = "") -> str: + """Get input from user using prompt_toolkit or fallback. + + Args: + pt_session: A prompt_toolkit PromptSession (or None). + project_name: Current project name. + modified: Whether project has unsaved changes. + context: Optional context string. + + Returns: + User input string (stripped). + """ + if pt_session is not None: + from prompt_toolkit.formatted_text import FormattedText + tokens = self.prompt_tokens(project_name, modified, context) + return pt_session.prompt(FormattedText(tokens)).strip() + else: + raw_prompt = self.prompt(project_name, modified, context) + return input(raw_prompt).strip() + + # ── Toolbar builder ─────────────────────────────────────────────── + + def bottom_toolbar(self, items: dict[str, str]): + """Create a bottom toolbar callback for prompt_toolkit. + + Args: + items: Dict of label -> value pairs to show in toolbar. + + Returns: + A callable that returns FormattedText for the toolbar. + """ + def toolbar(): + from prompt_toolkit.formatted_text import FormattedText + parts = [] + for i, (k, v) in enumerate(items.items()): + if i > 0: + parts.append(("class:bottom-toolbar.text", " │ ")) + parts.append(("class:bottom-toolbar.text", f" {k}: ")) + parts.append(("class:bottom-toolbar", v)) + return FormattedText(parts) + return toolbar + + +# ── ANSI 256-color to hex mapping (for prompt_toolkit styles) ───────── + +_ANSI_256_TO_HEX = { + "\033[38;5;33m": "#0087ff", # audacity navy blue + "\033[38;5;35m": "#00af5f", # shotcut teal + "\033[38;5;39m": "#00afff", # inkscape bright blue + "\033[38;5;40m": "#00d700", # libreoffice green + "\033[38;5;55m": "#5f00af", # obs purple + "\033[38;5;69m": "#5f87ff", # kdenlive slate blue + "\033[38;5;75m": "#5fafff", # default sky blue + "\033[38;5;80m": "#5fd7d7", # brand cyan + "\033[38;5;208m": "#ff8700", # blender deep orange + "\033[38;5;214m": "#ffaf00", # gimp warm orange +} diff --git a/anygen/agent-harness/setup.py b/anygen/agent-harness/setup.py new file mode 100644 index 0000000..33d85e3 --- /dev/null +++ b/anygen/agent-harness/setup.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +""" +setup.py for cli-anything-anygen + +Install with: pip install -e . +Or publish to PyPI: python -m build && twine upload dist/* +""" + +from setuptools import setup, find_namespace_packages + +with open("cli_anything/anygen/README.md", "r", encoding="utf-8") as fh: + long_description = fh.read() + +setup( + name="cli-anything-anygen", + version="1.0.0", + author="cli-anything contributors", + author_email="", + description="CLI harness for AnyGen OpenAPI - Generate docs, slides, websites and more via AnyGen cloud API. Requires: ANYGEN_API_KEY", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/HKUDS/CLI-Anything", + packages=find_namespace_packages(include=["cli_anything.*"]), + classifiers=[ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Office/Business", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + ], + python_requires=">=3.10", + install_requires=[ + "click>=8.0.0", + "requests>=2.28.0", + "prompt-toolkit>=3.0.0", + ], + extras_require={ + "dev": [ + "pytest>=7.0.0", + "pytest-cov>=4.0.0", + ], + }, + entry_points={ + "console_scripts": [ + "cli-anything-anygen=cli_anything.anygen.anygen_cli:main", + ], + }, + package_data={ + "cli_anything.anygen": ["skills/*.md"], + }, + include_package_data=True, + zip_safe=False, +) diff --git a/assets/.gitkeep b/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/assets/architecture.png b/assets/architecture.png new file mode 100644 index 0000000..6a525a8 Binary files /dev/null and b/assets/architecture.png differ diff --git a/assets/cli-typing.gif b/assets/cli-typing.gif new file mode 100644 index 0000000..67f44ac Binary files /dev/null and b/assets/cli-typing.gif differ diff --git a/assets/demos/README.md b/assets/demos/README.md new file mode 100644 index 0000000..9895988 --- /dev/null +++ b/assets/demos/README.md @@ -0,0 +1,101 @@ +# CLI-Anything Demo Assets + +This directory centralizes the GIFs and images used by the repository demo pages. +The top-level [README.md](../README.md) is the primary showcase; this file is the +asset-side landing page so the directory still makes sense on its own. + +--- + +## FreeCAD — Curiosity Rover via Preview, Live Preview, and Trajectory + +> **Harness:** `cli-anything-freecad` | **Preview Stack:** `preview` + `preview live` + `trajectory.json` | **Artifact:** Agent-built Curiosity-style rover + +An agent incrementally assembles a Curiosity-inspired rover while publishing real +FreeCAD preview bundles, refreshing a live preview session, and recording +command-to-preview history for later replay. + +

+ FreeCAD Curiosity rover demo driven by preview, live preview, and trajectory history +

+ +

+ README GIF generated from the full local demo video with a speed-adjusted, high-quality ffmpeg palette workflow. +

+ +--- + +## Blender — Orbital Relay Drone via Preview, Live Preview, and Trajectory + +> **Harness:** `cli-anything-blender` | **Preview Stack:** `preview` + `preview live` + `trajectory.json` | **Artifact:** Agent-built orbital relay drone + +An agent uses the Blender harness to grow a hard-surface orbital relay drone under +a real preview loop: each stage pushes new render-backed bundles, the live +session tracks the current head, and the trajectory ties every command to the +matching visual state. + +

+ Blender orbital relay drone demo driven by preview, live preview, and trajectory history +

+ +

+ README GIF generated from the full local demo video with a speed-adjusted, high-quality ffmpeg palette workflow. +

+ +--- + +## Draw.io — HTTPS Handshake Diagram + +> **Harness:** `cli-anything-drawio` | **Time:** ~4 min | **Artifact:** `.drawio` + `.png` + +An agent creates a full HTTPS connection lifecycle diagram from scratch through +CLI commands only. + +

+ Draw.io CLI demo: building an HTTPS handshake diagram +

+ +
+Final artifact +

+ HTTPS handshake sequence diagram +

+
+ +*Contributed by [@zhangxilong-43](https://github.com/zhangxilong-43)* + +--- + +## Slay the Spire II — Game Automation + +> **Harness:** `cli-anything-slay-the-spire-ii` | **Artifact:** Automated gameplay session + +An agent plays through a Slay the Spire II run using the CLI harness, making +real-time strategic decisions from game state. + +

+ Slay the Spire II CLI gameplay demo +

+ +*Contributed by [@TianyuFan0504](https://github.com/TianyuFan0504)* + +--- + +## VideoCaptioner — Auto-Generated Subtitles + +> **Harness:** `cli-anything-videocaptioner` | **Artifact:** Captioned video frames + +An agent uses the VideoCaptioner CLI to automatically generate and overlay styled +subtitles onto video content. + + + + + + + + + + +
Sub ASub B
Video frame before captioningVideo frame after captioning
+ +*Contributed by [@WEIFENG2333](https://github.com/WEIFENG2333)* diff --git a/assets/demos/arcgis-pro-live-bridge-demo.gif b/assets/demos/arcgis-pro-live-bridge-demo.gif new file mode 100644 index 0000000..645a33f Binary files /dev/null and b/assets/demos/arcgis-pro-live-bridge-demo.gif differ diff --git a/assets/demos/blender-orbital-relay-drone-preview-trajectory.gif b/assets/demos/blender-orbital-relay-drone-preview-trajectory.gif new file mode 100644 index 0000000..501b2eb Binary files /dev/null and b/assets/demos/blender-orbital-relay-drone-preview-trajectory.gif differ diff --git a/assets/demos/drawio-demo.gif b/assets/demos/drawio-demo.gif new file mode 100644 index 0000000..6a07b98 Binary files /dev/null and b/assets/demos/drawio-demo.gif differ diff --git a/assets/demos/drawio-https-handshake.png b/assets/demos/drawio-https-handshake.png new file mode 100644 index 0000000..83a7eb1 Binary files /dev/null and b/assets/demos/drawio-https-handshake.png differ diff --git a/assets/demos/freecad-curiosity-preview-trajectory.gif b/assets/demos/freecad-curiosity-preview-trajectory.gif new file mode 100644 index 0000000..65bb8ee Binary files /dev/null and b/assets/demos/freecad-curiosity-preview-trajectory.gif differ diff --git a/assets/demos/slay-the-spire-ii-gameplay.gif b/assets/demos/slay-the-spire-ii-gameplay.gif new file mode 100644 index 0000000..dd87679 Binary files /dev/null and b/assets/demos/slay-the-spire-ii-gameplay.gif differ diff --git a/assets/demos/videocaptioner-after.png b/assets/demos/videocaptioner-after.png new file mode 100644 index 0000000..fe30d9c Binary files /dev/null and b/assets/demos/videocaptioner-after.png differ diff --git a/assets/demos/videocaptioner-before.png b/assets/demos/videocaptioner-before.png new file mode 100644 index 0000000..f436288 Binary files /dev/null and b/assets/demos/videocaptioner-before.png differ diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..57442f9 Binary files /dev/null and b/assets/icon.png differ diff --git a/assets/teaser.png b/assets/teaser.png new file mode 100644 index 0000000..37f923a Binary files /dev/null and b/assets/teaser.png differ diff --git a/audacity/agent-harness/AUDACITY.md b/audacity/agent-harness/AUDACITY.md new file mode 100644 index 0000000..1761292 --- /dev/null +++ b/audacity/agent-harness/AUDACITY.md @@ -0,0 +1,167 @@ +# Audacity: Project-Specific Analysis & SOP + +## Architecture Summary + +Audacity is a multi-platform audio editor built on PortAudio for I/O and +libsndfile for file format support. Its native `.aup3` format is a SQLite +database containing audio data and project metadata. + +``` ++-------------------------------------------------+ +| Audacity GUI | +| +----------+ +----------+ +----------------+ | +| | Timeline | | Mixer | | Effects | | +| | (wxGTK) | | (wxGTK) | | (wxGTK) | | +| +----+-----+ +----+-----+ +------+---------+ | +| | | | | +| +----+-------------+--------------+----------+ | +| | Internal Audio Engine | | +| | Block-based audio storage, real-time | | +| | processing, effect chain, undo history | | +| +--------------------+-----------------------+ | ++------------------------+------------------------+ + | + +--------------+--------------+ + | PortAudio (I/O) | libsndfile | + | SoX resampler | LAME (MP3) | + +---------------------------------+ +``` + +## CLI Strategy: Python stdlib + JSON Project + +Unlike applications with XML project files, Audacity's .aup3 is SQLite, +making direct manipulation complex. Our strategy: + +1. **JSON project format** tracks all state (tracks, clips, effects, labels) +2. **Python stdlib** (`wave`, `struct`, `math`) handles WAV I/O and audio processing +3. **pydub** (optional) for advanced format support (MP3, FLAC, OGG) + +### Why Not .aup3 Directly? + +The .aup3 format is a SQLite database with: +- Binary audio block storage (custom compression) +- Complex relational schema for tracks, clips, envelopes +- Undo history embedded in the database +- Project metadata interleaved with audio data + +Parsing and writing this format requires deep knowledge of Audacity internals. +Instead, we use a JSON manifest and render to standard audio formats. + +## The Project Format (.audacity-cli.json) + +```json +{ + "version": "1.0", + "name": "my_podcast", + "settings": { + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2 + }, + "tracks": [...], + "labels": [...], + "selection": {"start": 0.0, "end": 0.0}, + "metadata": {"title": "", "artist": "", "album": "", ...} +} +``` + +## Command Map: GUI Action -> CLI Command + +| GUI Action | CLI Command | +|-----------|-------------| +| File -> New | `project new --name "My Project"` | +| File -> Open | `project open ` | +| File -> Save | `project save [path]` | +| File -> Export Audio | `export render [--preset wav]` | +| Tracks -> Add New -> Audio | `track add --name "Track"` | +| Track -> Remove | `track remove ` | +| Track -> Mute/Solo | `track set mute true` | +| Track -> Volume | `track set volume 0.8` | +| Track -> Pan | `track set pan -0.5` | +| File -> Import -> Audio | `clip add ` | +| Edit -> Remove | `clip remove ` | +| Edit -> Clip Boundaries -> Split | `clip split