chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# Default reviewers for every pull request.
|
||||
* @yuh-yang @zhangxilong-43 @omerarslan0
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1,66 @@
|
||||
## Description
|
||||
|
||||
<!-- Briefly describe the changes in this PR. -->
|
||||
|
||||
Fixes #<!-- issue number -->
|
||||
|
||||
## Type of Change
|
||||
|
||||
<!-- Check the one that applies: -->
|
||||
|
||||
- [ ] **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)
|
||||
|
||||
<!-- If this PR adds a new software CLI inside the monorepo, ALL items below must be checked. -->
|
||||
|
||||
- [ ] `<SOFTWARE>.md` SOP document exists at `<software>/agent-harness/<SOFTWARE>.md`
|
||||
- [ ] Canonical `SKILL.md` exists at `skills/cli-anything-<software>/SKILL.md`
|
||||
- [ ] Packaged compatibility `SKILL.md` exists at `cli_anything/<software>/skills/SKILL.md`
|
||||
- [ ] Unit tests at `cli_anything/<software>/tests/test_core.py` are present and pass without backend
|
||||
- [ ] E2E tests at `cli_anything/<software>/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)
|
||||
|
||||
<!-- If this PR only adds a registry.json entry pointing to an external repo, ALL items below must be checked. -->
|
||||
|
||||
- [ ] CLI is installable via `pip install <package-name>` 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
|
||||
|
||||
<!-- If this PR modifies an existing harness, ALL items below must be checked. -->
|
||||
|
||||
- [ ] All unit tests pass: `python3 -m pytest cli_anything/<software>/tests/test_core.py -v`
|
||||
- [ ] All E2E tests pass: `python3 -m pytest cli_anything/<software>/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
|
||||
|
||||
<!-- Paste the output of `pytest -v` for the affected harness(es). -->
|
||||
|
||||
```
|
||||
<paste test output here>
|
||||
```
|
||||
@@ -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"
|
||||
@@ -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 <name> [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-<name>` 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-<name>",
|
||||
"",
|
||||
"# One-shot command",
|
||||
"cli-anything-<name> <group> <command> [options]",
|
||||
"",
|
||||
"# JSON output for agents",
|
||||
"cli-anything-<name> --json <group> <command>",
|
||||
"```",
|
||||
"",
|
||||
"## For AI Agents",
|
||||
"",
|
||||
"1. Install the hub: `pip install cli-anything-hub`",
|
||||
"2. Install the CLI you need: `cli-hub install <name>`",
|
||||
"3. Run the CLI directly via its entry point, or use `cli-hub launch <name> [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()
|
||||
@@ -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;
|
||||
@@ -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())
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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/<name>/{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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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/
|
||||
+345
@@ -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/
|
||||
@@ -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 <path-or-repo>` | Build a complete CLI harness for any GUI application |
|
||||
| `/cli-anything:refine <path> [focus]` | Refine an existing CLI harness to improve coverage |
|
||||
| `/cli-anything:test <path-or-repo>` | Run tests for a CLI harness and update TEST.md |
|
||||
| `/cli-anything:validate <path-or-repo>` | 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/<software>/` | Current working directory |
|
||||
| `cli-anything-plugin/repl_skin.py` | Resolved from `cli-anything-plugin/` (single source of truth) |
|
||||
| `~/.claude/plugins/cli-anything/` | `<extension>/` |
|
||||
|
||||
## 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
|
||||
@@ -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 <path-or-repo> - Build a complete CLI harness
|
||||
* /cli-anything:refine <path> [focus] - Refine an existing CLI harness
|
||||
* /cli-anything:test <path-or-repo> - Run tests for a CLI harness
|
||||
* /cli-anything:validate <path-or-repo> - 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/<software>/\` → 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 <path-or-repo> ─────────────────────────────────
|
||||
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 <path-or-repo>\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 <path> [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 <software-path> [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 <path-or-repo> ────────────────────────────
|
||||
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 <software-path-or-repo>\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 <path-or-repo> ────────────────────────
|
||||
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 <software-path-or-repo>\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)");
|
||||
},
|
||||
});
|
||||
}
|
||||
Executable
+139
@@ -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 <path-or-repo> Build a CLI harness"
|
||||
echo " /cli-anything:refine <path> [focus] Refine a harness"
|
||||
echo " /cli-anything:test <path-or-repo> Test a harness"
|
||||
echo " /cli-anything:validate <path> Validate a harness"
|
||||
echo " /cli-anything:list [options] List all CLI tools"
|
||||
echo ""
|
||||
echo " Run '/reload' in Pi or restart Pi to activate."
|
||||
echo ""
|
||||
@@ -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<string, string> = {
|
||||
"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<typeof createMockPi>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 `<triangle>` 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.
|
||||
@@ -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 <file>` | Show mesh statistics (vertices, faces, bounds, watertight, volume) |
|
||||
| `inspect <file>` | Detect and list all cylindrical holes with diameter and position |
|
||||
| `resize <file>` | Resize specified holes to a target diameter |
|
||||
| `repair <file>` | Fix degenerate faces, duplicate vertices, and normals |
|
||||
| `compare <f1> <f2>` | 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)
|
||||
@@ -0,0 +1 @@
|
||||
"""3MF CLI - Mesh geometry editor for 3D printing files."""
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Allow running as python -m cli_anything.threemf"""
|
||||
from cli_anything.threemf.threemf_cli import main
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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))
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 ``<object>`` 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 ``<metadata>`` 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 ``<object>`` element.
|
||||
|
||||
Returns ``None`` when the element has no ``<mesh>`` 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 ``<model>`` 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 ``<object>`` elements with ``<mesh>`` children.
|
||||
5. Collect ``<metadata>`` 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 ``<object>`` whose id matches one of our :class:`MeshData`
|
||||
objects, replace its ``<mesh>`` 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 ``<mesh>`` 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 ``<model>`` 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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 <file.3mf>
|
||||
|
||||
# Detect cylindrical holes (center, diameter, confidence)
|
||||
cli-anything-3mf inspect <file.3mf>
|
||||
|
||||
# Resize holes to target diameter
|
||||
cli-anything-3mf resize <file.3mf> --hole 0 --hole 1 --diameter 4.2 -o output.3mf
|
||||
|
||||
# Fix mesh issues (degenerate faces, duplicate vertices, normals)
|
||||
cli-anything-3mf repair <file.3mf> -o repaired.3mf
|
||||
|
||||
# Compare two 3MF files
|
||||
cli-anything-3mf compare <file1.3mf> <file2.3mf>
|
||||
```
|
||||
|
||||
## 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
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 <file>": "Show mesh statistics",
|
||||
"inspect <file>": "Detect cylindrical holes",
|
||||
"resize <file> -h <id> -d <mm> -o <out>": "Resize holes",
|
||||
"repair <file> -o <out>": "Fix mesh issues",
|
||||
"compare <f1> <f2>": "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 <file>": "Show mesh statistics",
|
||||
"inspect <file>": "Detect cylindrical holes",
|
||||
"resize <file> -h <id> -d <mm> -o <out>": "Resize holes",
|
||||
"repair <file> -o <out>": "Fix mesh issues",
|
||||
"compare <f1> <f2>": "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()
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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/<software>/utils/repl_skin.py
|
||||
|
||||
Usage:
|
||||
from cli_anything.<software>.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-<software>/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/<software>/utils/repl_skin.py (this file)
|
||||
# cli_anything/<software>/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
|
||||
}
|
||||
@@ -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 ``<object id="...">`` attribute to assign.
|
||||
name:
|
||||
The ``<object name="...">`` 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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"
|
||||
+188
@@ -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 `<software>/agent-harness/` and ensure the following:
|
||||
|
||||
1. **`<SOFTWARE>.md`** — the SOP document exists at `<software>/agent-harness/<SOFTWARE>.md` describing the harness architecture.
|
||||
2. **`SKILL.md`** — the canonical AI-discoverable skill definition exists at `skills/cli-anything-<software>/SKILL.md`, and the packaged compatibility copy exists at `cli_anything/<software>/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 <package-name>` (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 `<name>/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 `<software>/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 <software>/agent-harness
|
||||
pip install -e .
|
||||
|
||||
# Run tests
|
||||
python3 -m pytest cli_anything/<software>/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/<software>/tests/test_core.py -v
|
||||
|
||||
# E2E tests (requires real backend installed)
|
||||
python3 -m pytest cli_anything/<software>/tests/test_full_e2e.py -v
|
||||
|
||||
# All tests for a harness
|
||||
python3 -m pytest cli_anything/<software>/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`.
|
||||
@@ -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 <id>`)
|
||||
- generic algorithm execution (`run <id>`)
|
||||
- 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:
|
||||
|
||||
- `<project>.qgz` -> `<project>_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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,3 @@
|
||||
"""cli-anything-qgis package."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Run cli-anything-qgis as a module."""
|
||||
|
||||
from cli_anything.qgis.qgis_cli import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
"""Core domain modules for cli-anything-qgis."""
|
||||
@@ -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", []),
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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", []),
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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", [])]
|
||||
@@ -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()
|
||||
@@ -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 <path>` 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
|
||||
```
|
||||
@@ -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 ========================
|
||||
```
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -0,0 +1 @@
|
||||
"""Utility helpers for cli-anything-qgis."""
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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/<software>/utils/repl_skin.py
|
||||
|
||||
Usage:
|
||||
from cli_anything.<software>.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-<software>/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
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
addopts = --import-mode=importlib
|
||||
@@ -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,
|
||||
)
|
||||
+1049
File diff suppressed because it is too large
Load Diff
+1138
File diff suppressed because it is too large
Load Diff
+68
@@ -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.
|
||||
@@ -0,0 +1,11 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
.fastembed_cache/
|
||||
.leann/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
@@ -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://<host>:<port>/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
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Enable python -m cli_anything.adguardhome"""
|
||||
from cli_anything.adguardhome.adguardhome_cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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"])
|
||||
@@ -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})
|
||||
@@ -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})
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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})
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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})
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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})
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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/<software>/utils/repl_skin.py
|
||||
|
||||
Usage:
|
||||
from cli_anything.<software>.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-<software>/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-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/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
|
||||
}
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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 <task-id>` |
|
||||
| Poll until completion | `task poll <task-id> [--output ./]` |
|
||||
| Download result file | `task download <task-id> --output ./` |
|
||||
| Download thumbnail | `task thumbnail <task-id> --output ./` |
|
||||
| Upload a reference file | `file upload <path>` |
|
||||
| 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)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user