chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "cli-anything",
|
||||
"description": "Build powerful, stateful CLI interfaces for any GUI application using the cli-anything harness methodology.",
|
||||
"author": {
|
||||
"name": "cli-anything contributors"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,747 @@
|
||||
# Agent Harness: GUI-to-CLI for Open Source Software
|
||||
|
||||
## Purpose
|
||||
|
||||
This harness provides a standard operating procedure (SOP) and toolkit for coding
|
||||
agents (Claude Code, Codex, etc.) to build powerful, stateful CLI interfaces for
|
||||
open-source GUI applications. The goal: let AI agents operate software that was
|
||||
designed for humans, without needing a display or mouse.
|
||||
|
||||
## General SOP: Turning Any GUI App into an Agent-Usable CLI
|
||||
|
||||
### Phase 1: Codebase Analysis
|
||||
|
||||
1. **Identify the backend engine** — Most GUI apps separate presentation from logic.
|
||||
Find the core library/framework (e.g., MLT for Shotcut, ImageMagick for GIMP).
|
||||
2. **Map GUI actions to API calls** — Every button click, drag, and menu item
|
||||
corresponds to a function call. Catalog these mappings.
|
||||
3. **Identify the data model** — What file formats does it use? How is project state
|
||||
represented? (XML, JSON, binary, database?)
|
||||
4. **Find existing CLI tools** — Many backends ship their own CLI (`melt`, `ffmpeg`,
|
||||
`convert`). These are building blocks.
|
||||
5. **Catalog the command/undo system** — If the app has undo/redo, it likely uses a
|
||||
command pattern. These commands are your CLI operations.
|
||||
|
||||
### Phase 2: CLI Architecture Design
|
||||
|
||||
1. **Choose the interaction model**:
|
||||
- **Stateful REPL** for interactive sessions (agents that maintain context)
|
||||
- **Subcommand CLI** for one-shot operations (scripting, pipelines)
|
||||
- **Both** (recommended) — a CLI that works in both modes
|
||||
|
||||
2. **Define command groups** matching the app's logical domains:
|
||||
- Project management (new, open, save, close)
|
||||
- Core operations (the app's primary purpose)
|
||||
- Import/Export (file I/O, format conversion)
|
||||
- Configuration (settings, preferences, profiles)
|
||||
- Session/State management (undo, redo, history, status)
|
||||
|
||||
3. **Design the state model**:
|
||||
- What must persist between commands? (open project, cursor position, selection)
|
||||
- Where is state stored? (in-memory for REPL, file-based for CLI)
|
||||
- How does state serialize? (JSON session files)
|
||||
|
||||
4. **Plan the output format**:
|
||||
- Human-readable (tables, colors) for interactive use
|
||||
- Machine-readable (JSON) for agent consumption
|
||||
- Both, controlled by `--json` flag
|
||||
|
||||
### Phase 3: Implementation
|
||||
|
||||
1. **Start with the data layer** — XML/JSON manipulation of project files
|
||||
2. **Add probe/info commands** — Let agents inspect before they modify
|
||||
3. **Add mutation commands** — One command per logical operation
|
||||
4. **Add the backend integration** — A `utils/<software>_backend.py` module that
|
||||
wraps the real software's CLI. This module handles:
|
||||
- Finding the software executable (`shutil.which()`)
|
||||
- Invoking it with proper arguments (`subprocess.run()`)
|
||||
- Error handling with clear install instructions if not found
|
||||
- Example (LibreOffice):
|
||||
```python
|
||||
# utils/lo_backend.py
|
||||
def convert_odf_to(odf_path, output_format, output_path=None, overwrite=False):
|
||||
lo = find_libreoffice() # raises RuntimeError with install instructions
|
||||
subprocess.run([lo, "--headless", "--convert-to", output_format, ...])
|
||||
return {"output": final_path, "format": output_format, "method": "libreoffice-headless"}
|
||||
```
|
||||
5. **Add rendering/export** — The export pipeline calls the backend module.
|
||||
Generate valid intermediate files, then invoke the real software for conversion.
|
||||
6. **Add session management** — State persistence, undo/redo
|
||||
|
||||
**Session file locking** — Use exclusive file locking for session JSON saves
|
||||
to prevent concurrent write corruption. See [`guides/session-locking.md`](guides/session-locking.md)
|
||||
for the `_locked_save_json` pattern (open `"r+"`, lock, then truncate inside the lock).
|
||||
7. **Add the REPL with unified skin** — Interactive mode wrapping the subcommands.
|
||||
- Copy `repl_skin.py` from the plugin (`cli-anything-plugin/repl_skin.py`) into
|
||||
`utils/repl_skin.py` in your CLI package
|
||||
- Import and use `ReplSkin` for the REPL interface:
|
||||
```python
|
||||
from cli_anything.<software>.utils.repl_skin import ReplSkin
|
||||
|
||||
skin = ReplSkin("<software>", version="1.0.0")
|
||||
skin.print_banner() # Branded startup box (prefers repo-root skills/, falls back to package)
|
||||
pt_session = skin.create_prompt_session() # prompt_toolkit with history + styling
|
||||
line = skin.get_input(pt_session, project_name="my_project", modified=True)
|
||||
skin.help(commands_dict) # Formatted help listing
|
||||
skin.success("Saved") # ✓ green message
|
||||
skin.error("Not found") # ✗ red message
|
||||
skin.warning("Unsaved") # ⚠ yellow message
|
||||
skin.info("Processing...") # ● blue message
|
||||
skin.status("Key", "value") # Key-value status line
|
||||
skin.table(headers, rows) # Formatted table
|
||||
skin.progress(3, 10, "...") # Progress bar
|
||||
skin.print_goodbye() # Styled exit message
|
||||
```
|
||||
- ReplSkin prefers the repo-root canonical `skills/cli-anything-<software>/SKILL.md`
|
||||
when running inside this monorepo, and falls back to the packaged
|
||||
`cli_anything/<software>/skills/SKILL.md` copy when installed elsewhere.
|
||||
AI agents can read the skill file at the displayed absolute path.
|
||||
- Make REPL the default behavior: use `invoke_without_command=True` on the main
|
||||
Click group, and invoke the `repl` command when no subcommand is given:
|
||||
```python
|
||||
@click.group(invoke_without_command=True)
|
||||
@click.pass_context
|
||||
def cli(ctx, ...):
|
||||
...
|
||||
if ctx.invoked_subcommand is None:
|
||||
ctx.invoke(repl, project_path=None)
|
||||
```
|
||||
- This ensures `cli-anything-<software>` with no arguments enters the REPL
|
||||
|
||||
### Phase 4: Test Planning (TEST.md - Part 1)
|
||||
|
||||
**BEFORE writing any test code**, create a `TEST.md` file in the
|
||||
`agent-harness/cli_anything/<software>/tests/` directory. This file serves as your test plan and
|
||||
MUST contain:
|
||||
|
||||
1. **Test Inventory Plan** — List planned test files and estimated test counts:
|
||||
- `test_core.py`: XX unit tests planned
|
||||
- `test_full_e2e.py`: XX E2E tests planned
|
||||
|
||||
2. **Unit Test Plan** — For each core module, describe what will be tested:
|
||||
- Module name (e.g., `project.py`)
|
||||
- Functions to test
|
||||
- Edge cases to cover (invalid inputs, boundary conditions, error handling)
|
||||
- Expected test count
|
||||
|
||||
3. **E2E Test Plan** — Describe the real-world scenarios to test:
|
||||
- What workflows will be simulated?
|
||||
- What real files will be generated/processed?
|
||||
- What output properties will be verified?
|
||||
- What format validations will be performed?
|
||||
|
||||
4. **Realistic Workflow Scenarios** — Detail each multi-step workflow:
|
||||
- **Workflow name**: Brief title
|
||||
- **Simulates**: What real-world task (e.g., "photo editing pipeline",
|
||||
"podcast production", "product render setup")
|
||||
- **Operations chained**: Step-by-step operations
|
||||
- **Verified**: What output properties will be checked
|
||||
|
||||
This planning document ensures comprehensive test coverage before writing code.
|
||||
|
||||
### Phase 5: Test Implementation
|
||||
|
||||
Now write the actual test code based on the TEST.md plan:
|
||||
|
||||
1. **Unit tests** (`test_core.py`) — Every core function tested in isolation with
|
||||
synthetic data. No external dependencies.
|
||||
2. **E2E tests — intermediate files** (`test_full_e2e.py`) — Verify the project files
|
||||
your CLI generates are structurally correct (valid XML, correct ZIP structure, etc.)
|
||||
3. **E2E tests — true backend** (`test_full_e2e.py`) — **MUST invoke the real software.**
|
||||
Create a project, export via the actual software backend, and verify the output:
|
||||
- File exists and size > 0
|
||||
- Correct format (PDF magic bytes `%PDF-`, DOCX/XLSX/PPTX is valid ZIP/OOXML, etc.)
|
||||
- Content verification where possible (CSV contains expected data, etc.)
|
||||
- **Print artifact paths** so users can manually inspect: `print(f"\n PDF: {path} ({size:,} bytes)")`
|
||||
- **No graceful degradation** — if the software isn't installed, tests fail, not skip
|
||||
4. **Output verification** — **Don't trust that export works just because it exits
|
||||
successfully.** Verify outputs programmatically:
|
||||
- Magic bytes / file format validation
|
||||
- ZIP structure for OOXML formats (DOCX, XLSX, PPTX)
|
||||
- Pixel-level analysis for video/images (probe frames, compare brightness)
|
||||
- Audio analysis (RMS levels, spectral comparison)
|
||||
- Duration/format checks against expected values
|
||||
5. **CLI subprocess tests** — Test the installed CLI command as a real user/agent would.
|
||||
The subprocess tests MUST also produce real final output (not just ODF intermediate).
|
||||
Use the `_resolve_cli` helper to run the installed `cli-anything-<software>` command:
|
||||
```python
|
||||
def _resolve_cli(name):
|
||||
"""Resolve installed CLI command; falls back to python -m for dev.
|
||||
|
||||
Set env CLI_ANYTHING_FORCE_INSTALLED=1 to require the installed command.
|
||||
"""
|
||||
import shutil
|
||||
force = os.environ.get("CLI_ANYTHING_FORCE_INSTALLED", "").strip() == "1"
|
||||
path = shutil.which(name)
|
||||
if path:
|
||||
print(f"[_resolve_cli] Using installed command: {path}")
|
||||
return [path]
|
||||
if force:
|
||||
raise RuntimeError(f"{name} not found in PATH. Install with: pip install -e .")
|
||||
module = name.replace("cli-anything-", "cli_anything.") + "." + name.split("-")[-1] + "_cli"
|
||||
print(f"[_resolve_cli] Falling back to: {sys.executable} -m {module}")
|
||||
return [sys.executable, "-m", module]
|
||||
|
||||
|
||||
class TestCLISubprocess:
|
||||
CLI_BASE = _resolve_cli("cli-anything-<software>")
|
||||
|
||||
def _run(self, args, check=True):
|
||||
return subprocess.run(
|
||||
self.CLI_BASE + args,
|
||||
capture_output=True, text=True,
|
||||
check=check,
|
||||
)
|
||||
|
||||
def test_help(self):
|
||||
result = self._run(["--help"])
|
||||
assert result.returncode == 0
|
||||
|
||||
def test_project_new_json(self, tmp_dir):
|
||||
out = os.path.join(tmp_dir, "test.json")
|
||||
result = self._run(["--json", "project", "new", "-o", out])
|
||||
assert result.returncode == 0
|
||||
data = json.loads(result.stdout)
|
||||
# ... verify structure
|
||||
```
|
||||
|
||||
**Key rules for subprocess tests:**
|
||||
- Always use `_resolve_cli("cli-anything-<software>")` — never hardcode
|
||||
`sys.executable` or module paths directly
|
||||
- Do NOT set `cwd` — installed commands must work from any directory
|
||||
- Use `CLI_ANYTHING_FORCE_INSTALLED=1` in CI/release testing to ensure the
|
||||
installed command (not a fallback) is being tested
|
||||
- Test `--help`, `--json`, project creation, key commands, and full workflows
|
||||
|
||||
6. **Round-trip test** — Create project via CLI, open in GUI, verify correctness
|
||||
7. **Agent test** — Have an AI agent complete a real task using only the CLI
|
||||
|
||||
### Phase 6: Test Documentation (TEST.md - Part 2)
|
||||
|
||||
After running all tests successfully, **append** to the existing TEST.md:
|
||||
|
||||
1. **Test Results** — Paste the full `pytest -v --tb=no` output showing all tests
|
||||
passing with their names and status
|
||||
2. **Summary Statistics** — Total tests, pass rate, execution time
|
||||
3. **Coverage Notes** — Any gaps or areas not covered by tests
|
||||
|
||||
The TEST.md now serves as both the test plan (written before implementation) and
|
||||
the test results documentation (appended after execution), providing a complete
|
||||
record of the testing process.
|
||||
|
||||
### Phase 6.5: SKILL.md Generation
|
||||
|
||||
Generate a SKILL.md file that makes the CLI discoverable and usable by AI agents
|
||||
through the skill-creator methodology. This file serves as a self-contained skill
|
||||
definition that can be loaded by Claude Code or other AI assistants.
|
||||
|
||||
**Purpose:** SKILL.md files follow a standard format that enables AI agents to:
|
||||
- Discover the CLI's capabilities
|
||||
- Understand command structure and usage
|
||||
- Generate correct command invocations
|
||||
- Handle output programmatically
|
||||
|
||||
**SKILL.md Structure:**
|
||||
|
||||
1. **YAML Frontmatter** — Triggering metadata for skill discovery:
|
||||
```yaml
|
||||
---
|
||||
name: "cli-anything-<software>"
|
||||
description: "Brief description of what the CLI does"
|
||||
---
|
||||
```
|
||||
|
||||
2. **Markdown Body** — Installation prerequisites, command syntax, command groups,
|
||||
usage examples, and agent-specific guidance (JSON output, error handling).
|
||||
|
||||
**Generation & Customization:** Use `skill_generator.py` to extract CLI metadata
|
||||
automatically, or customize via the Jinja2 template at `templates/SKILL.md.template`.
|
||||
See [`guides/skill-generation.md`](guides/skill-generation.md) for the full generation
|
||||
process, template customization options, and manual generation commands.
|
||||
|
||||
**Output Location:** The canonical skill lives at
|
||||
`skills/cli-anything-<software>/SKILL.md`. A compatibility copy is also written to
|
||||
`cli_anything/<software>/skills/SKILL.md` so installed harnesses still ship a
|
||||
local skill file.
|
||||
|
||||
**Key Principles:**
|
||||
|
||||
- SKILL.md must be self-contained (no external dependencies for understanding)
|
||||
- Include agent-specific guidance for programmatic usage
|
||||
- Document `--json` flag usage for machine-readable output
|
||||
- List all command groups with brief descriptions
|
||||
- Provide realistic examples that demonstrate common workflows
|
||||
- If the harness supports previews, document the producer surface
|
||||
(`cli-anything-<software> preview ...`) and the consumer surface
|
||||
(`cli-hub previews ...`) separately in both `README.md` and `SKILL.md`
|
||||
- If the harness supports live preview, explain what `preview live status --json`
|
||||
returns and how agents should use it as a cheap introspection call
|
||||
|
||||
**Skill Path in CLI Banner:**
|
||||
|
||||
ReplSkin prefers the repo-root canonical skill path and falls back to the
|
||||
packaged `skills/SKILL.md` copy. AI agents can read the displayed path to learn
|
||||
the CLI's full capabilities.
|
||||
|
||||
**Package Data:** Ensure `setup.py` includes the skill file so it ships with pip:
|
||||
|
||||
```python
|
||||
package_data={
|
||||
"cli_anything.<software>": ["skills/*.md"],
|
||||
},
|
||||
```
|
||||
|
||||
### Phase 7: PyPI Publishing and Installation
|
||||
|
||||
After building and testing the CLI, make it installable and discoverable using
|
||||
**PEP 420 namespace packages** under the shared `cli_anything` namespace.
|
||||
|
||||
See [`guides/pypi-publishing.md`](guides/pypi-publishing.md) for the full setup.py template,
|
||||
namespace package structure, import conventions, and verification steps.
|
||||
|
||||
**Key rule:** `cli_anything/` has **no** `__init__.py` (namespace package). Each
|
||||
sub-package (`gimp/`, `blender/`, etc.) **does** have `__init__.py`.
|
||||
|
||||
## Architecture Patterns & Pitfalls
|
||||
|
||||
### Use the Real Software — Don't Reimplement It
|
||||
|
||||
**This is the #1 rule.** The CLI MUST call the actual software for rendering and
|
||||
export — not reimplement the software's functionality in Python.
|
||||
|
||||
**The anti-pattern:** Building a Pillow-based image compositor to replace GIMP,
|
||||
or generating bpy scripts without ever calling Blender. This produces a toy that
|
||||
can't handle real workloads and diverges from the actual software's behavior.
|
||||
|
||||
**The correct approach:**
|
||||
1. **Use the software's CLI/scripting interface** as the backend:
|
||||
- LibreOffice: `libreoffice --headless --convert-to pdf/docx/xlsx/pptx`
|
||||
- Blender: `blender --background --python script.py`
|
||||
- GIMP: `gimp -i -b '(script-fu-console-eval ...)'`
|
||||
- Inkscape: `inkscape --actions="..." --export-filename=...`
|
||||
- Shotcut/Kdenlive: `melt project.mlt -consumer avformat:output.mp4`
|
||||
- Audacity: `sox` for effects processing
|
||||
- OBS: `obs-websocket` protocol
|
||||
|
||||
2. **The software is a required dependency**, not optional. Add it to installation
|
||||
instructions. The CLI is useless without the actual software.
|
||||
|
||||
3. **Generate valid project/intermediate files** (ODF, MLT XML, .blend, SVG, etc.)
|
||||
then hand them to the real software for rendering. Your CLI is a structured
|
||||
command-line interface to the software, not a replacement for it.
|
||||
|
||||
**Example — LibreOffice CLI export pipeline:**
|
||||
```python
|
||||
# 1. Build the document as a valid ODF file (our XML builder)
|
||||
odf_path = write_odf(tmp_path, doc_type, project)
|
||||
|
||||
# 2. Convert via the REAL LibreOffice (not a reimplementation)
|
||||
subprocess.run([
|
||||
"libreoffice", "--headless",
|
||||
"--convert-to", "pdf",
|
||||
"--outdir", output_dir,
|
||||
odf_path,
|
||||
])
|
||||
# Result: a real PDF rendered by LibreOffice's full engine
|
||||
```
|
||||
|
||||
### The Rendering Gap
|
||||
|
||||
**This is the #2 pitfall.** Most GUI apps apply effects at render time via their
|
||||
engine. When you build a CLI that manipulates project files directly, you must also
|
||||
handle rendering — and naive approaches will silently drop effects.
|
||||
|
||||
**The problem:** Your CLI adds filters/effects to the project file format. But when
|
||||
rendering, if you use a simple tool (e.g., ffmpeg concat demuxer), it reads raw
|
||||
media files and **ignores** all project-level effects. The output looks identical to
|
||||
the input. Users can't tell anything happened.
|
||||
|
||||
**The solution — a filter translation layer:**
|
||||
1. **Best case:** Use the app's native renderer (`melt` for MLT projects). It reads
|
||||
the project file and applies everything.
|
||||
2. **Fallback:** Build a translation layer that converts project-format effects into
|
||||
the rendering tool's native syntax (e.g., MLT filters → ffmpeg `-filter_complex`).
|
||||
3. **Last resort:** Generate a render script the user can run manually.
|
||||
|
||||
**Priority order for rendering:** native engine → translated filtergraph → script.
|
||||
|
||||
### MCP Backend Pattern
|
||||
|
||||
For software that exposes an MCP (Model Context Protocol) server instead of a traditional
|
||||
CLI (e.g., DOMShell for browser automation). See [`guides/mcp-backend.md`](guides/mcp-backend.md)
|
||||
for the full backend wrapper pattern, session management, daemon mode, and example implementations.
|
||||
|
||||
**Use when:** no native CLI exists, software has an MCP server, or you need agent-native tool integration.
|
||||
|
||||
### Filter Translation Pitfalls
|
||||
|
||||
When translating effects between formats (e.g., MLT → ffmpeg), watch for duplicate filter
|
||||
merging, interleaved stream ordering, parameter scale differences, and unmappable effects.
|
||||
See [`guides/filter-translation.md`](guides/filter-translation.md) for detailed rules and examples.
|
||||
|
||||
### Timecode Precision
|
||||
|
||||
Non-integer frame rates (29.97fps) cause cumulative rounding errors. Key rules: use
|
||||
`round()` not `int()`, use integer arithmetic for display, accept ±1 frame tolerance.
|
||||
See [`guides/timecode-precision.md`](guides/timecode-precision.md) for the full approach.
|
||||
|
||||
### Output Verification Methodology
|
||||
|
||||
Never assume an export is correct just because it ran without errors. Verify:
|
||||
|
||||
```python
|
||||
# Video: probe specific frames with ffmpeg
|
||||
# Frame 0 for fade-in (should be near-black)
|
||||
# Middle frames for color effects (compare brightness/saturation vs source)
|
||||
# Last frame for fade-out (should be near-black)
|
||||
|
||||
# When comparing pixel values between different resolutions,
|
||||
# exclude letterboxing/pillarboxing (black padding bars).
|
||||
# A vertical video in a horizontal frame will have ~40% black pixels.
|
||||
|
||||
# Audio: check RMS levels at start/end for fades
|
||||
# Compare spectral characteristics against source
|
||||
```
|
||||
|
||||
### Testing Strategy
|
||||
|
||||
Four test layers with complementary purposes:
|
||||
|
||||
1. **Unit tests** (`test_core.py`): Synthetic data, no external dependencies. Tests
|
||||
every function in isolation. Fast, deterministic, good for CI.
|
||||
2. **E2E tests — native** (`test_full_e2e.py`): Tests the project file generation
|
||||
pipeline (ODF structure, XML content, format validation). Verifies the
|
||||
intermediate files your CLI produces are correct.
|
||||
3. **E2E tests — true backend** (`test_full_e2e.py`): Invokes the **real software**
|
||||
(LibreOffice, Blender, melt, etc.) to produce final output files (PDF, DOCX,
|
||||
rendered images, videos). Verifies the output files:
|
||||
- Exist and have size > 0
|
||||
- Have correct format (magic bytes, ZIP structure, etc.)
|
||||
- Contain expected content where verifiable
|
||||
- **Print artifact paths** so users can manually inspect results
|
||||
4. **CLI subprocess tests** (in `test_full_e2e.py`): Invokes the installed
|
||||
`cli-anything-<software>` command via `subprocess.run` to run the full workflow
|
||||
end-to-end: create project → add content → export via real software → verify output.
|
||||
|
||||
**No graceful degradation.** The real software MUST be installed. Tests must NOT
|
||||
skip or fake results when the software is missing — the CLI is useless without it.
|
||||
The software is a hard dependency, not optional.
|
||||
|
||||
**Example — true E2E test for LibreOffice:**
|
||||
```python
|
||||
class TestWriterToPDF:
|
||||
def test_rich_writer_to_pdf(self, tmp_dir):
|
||||
proj = create_document(doc_type="writer", name="Report")
|
||||
add_heading(proj, text="Quarterly Report", level=1)
|
||||
add_table(proj, rows=3, cols=3, data=[...])
|
||||
|
||||
pdf_path = os.path.join(tmp_dir, "report.pdf")
|
||||
result = export(proj, pdf_path, preset="pdf", overwrite=True)
|
||||
|
||||
# Verify the REAL output file
|
||||
assert os.path.exists(result["output"])
|
||||
assert result["file_size"] > 1000 # Not suspiciously small
|
||||
with open(result["output"], "rb") as f:
|
||||
assert f.read(5) == b"%PDF-" # Validate format magic bytes
|
||||
print(f"\n PDF: {result['output']} ({result['file_size']:,} bytes)")
|
||||
|
||||
|
||||
class TestCLISubprocessE2E:
|
||||
CLI_BASE = _resolve_cli("cli-anything-libreoffice")
|
||||
|
||||
def test_full_writer_pdf_workflow(self, tmp_dir):
|
||||
proj_path = os.path.join(tmp_dir, "test.json")
|
||||
pdf_path = os.path.join(tmp_dir, "output.pdf")
|
||||
self._run(["document", "new", "-o", proj_path, "--type", "writer"])
|
||||
self._run(["--project", proj_path, "writer", "add-heading", "-t", "Title"])
|
||||
self._run(["--project", proj_path, "export", "render", pdf_path, "-p", "pdf", "--overwrite"])
|
||||
assert os.path.exists(pdf_path)
|
||||
with open(pdf_path, "rb") as f:
|
||||
assert f.read(5) == b"%PDF-"
|
||||
```
|
||||
|
||||
Run tests in force-installed mode to guarantee the real command is used:
|
||||
```bash
|
||||
CLI_ANYTHING_FORCE_INSTALLED=1 python3 -m pytest cli_anything/<software>/tests/ -v -s
|
||||
```
|
||||
The `-s` flag shows the `[_resolve_cli]` print output confirming which backend
|
||||
is being used and **prints artifact paths** for manual inspection.
|
||||
|
||||
Real-world workflow test scenarios should include:
|
||||
- Multi-segment editing (YouTube-style cut/trim)
|
||||
- Montage assembly (many short clips)
|
||||
- Picture-in-picture compositing
|
||||
- Color grading pipelines
|
||||
- Audio mixing (podcast-style)
|
||||
- Heavy undo/redo stress testing
|
||||
- Save/load round-trips of complex projects
|
||||
- Iterative refinement (add, modify, remove, re-add)
|
||||
|
||||
## Principles & Rules
|
||||
|
||||
These are non-negotiable. Every harness MUST follow all of them.
|
||||
|
||||
**Backend & Rendering:**
|
||||
- **The real software is a hard dependency.** The CLI MUST invoke the actual application
|
||||
(LibreOffice, Blender, GIMP, etc.) for rendering and export. Do NOT reimplement
|
||||
rendering in Python. Do NOT gracefully degrade to a fallback library. If the software
|
||||
is not installed, error with clear install instructions.
|
||||
- **Manipulate the native format directly** — Parse and modify the app's native project
|
||||
files (MLT XML, ODF, SVG, etc.) as the data layer.
|
||||
- **Leverage existing CLI tools** — Use `libreoffice --headless`, `blender --background`,
|
||||
`melt`, `ffmpeg`, `inkscape --actions`, `sox` as subprocesses for rendering.
|
||||
- **Verify rendering produces correct output** — See "The Rendering Gap" in
|
||||
Architecture Patterns & Pitfalls above.
|
||||
- **Every filter/effect in the registry MUST have a corresponding render mapping**
|
||||
or be explicitly documented as "project-only (not rendered)".
|
||||
|
||||
**CLI Design:**
|
||||
- **Fail loudly and clearly** — Agents need unambiguous error messages to self-correct.
|
||||
- **Be idempotent where possible** — Running the same command twice should be safe.
|
||||
- **Provide introspection** — `info`, `list`, `status` commands are critical for agents
|
||||
to understand current state before acting.
|
||||
- **Preview meaningful intermediate state** — Harnesses that can expose useful
|
||||
visual checkpoints SHOULD implement a `preview` command group with at least
|
||||
`preview recipes`, `preview capture`, and `preview latest`. Diff-oriented
|
||||
tools SHOULD also expose `preview diff`.
|
||||
- **Keep producer and viewer roles separate** — Harnesses publish preview state
|
||||
through `cli-anything-<software> preview ...`. `cli-hub previews ...` is the
|
||||
read-only consumer for inspect/html/watch/open and should not be treated as a
|
||||
render path.
|
||||
- **Use the shared preview bundle protocol** — Preview-capable harnesses SHOULD
|
||||
emit `preview-bundle/v1` bundles as described in `docs/PREVIEW_PROTOCOL.md`.
|
||||
The bundle is the contract; the renderer remains the real software.
|
||||
- **Live preview should separate head vs history** — When a harness supports
|
||||
`preview live ...`, it SHOULD persist:
|
||||
- `session.json` as the mutable current head
|
||||
- immutable bundle directories for each capture
|
||||
- `trajectory.json` as the append-only command-to-preview history
|
||||
- **Do not treat `_bundle_dir` as the permanent build identity** — It is only a
|
||||
single snapshot path. Stable live replay should anchor on `session_dir` plus
|
||||
`trajectory.json`.
|
||||
- **Make `preview live status --json` agent-cheap** — Live-preview harnesses
|
||||
SHOULD include a compact `trajectory_summary` in `preview live status --json`
|
||||
so agents do not need to read `trajectory.json` just to understand the latest
|
||||
command-to-bundle mapping.
|
||||
- **Do not screen-scrape GUI windows for previews** — Preview artifacts must be
|
||||
produced by the real backend or by native inspection/export paths operating
|
||||
on real project/capture state.
|
||||
- **JSON output mode** — Every command MUST support `--json` for machine parsing.
|
||||
- **Use the unified REPL skin** — Copy `cli-anything-plugin/repl_skin.py` to
|
||||
`utils/repl_skin.py` and use `ReplSkin` for banner, prompt, help, and messages.
|
||||
REPL MUST be the default behavior (`invoke_without_command=True`).
|
||||
|
||||
**Testing:**
|
||||
- **E2E tests MUST invoke the real software** and produce real output files (PDF, DOCX,
|
||||
rendered images, videos). Verify output exists, has correct format (magic bytes, ZIP
|
||||
structure), and print artifact paths for manual inspection. Never test only
|
||||
intermediate files.
|
||||
- **Every export/render function MUST be verified** with programmatic output analysis.
|
||||
"It ran without errors" is not sufficient.
|
||||
- **E2E tests MUST include subprocess tests** that invoke the installed
|
||||
`cli-anything-<software>` command via `_resolve_cli()`. Tests must work against
|
||||
the actual installed package, not just source imports.
|
||||
- **Test suites MUST include real-file E2E tests**, not just unit tests with synthetic
|
||||
data. Format assumptions break constantly with real media.
|
||||
|
||||
**Documentation:**
|
||||
- **Every `cli_anything/<software>/` directory MUST contain a `README.md`** explaining
|
||||
how to install the software dependency, install the CLI, run tests, and basic usage.
|
||||
- **Every `cli_anything/<software>/tests/` directory MUST contain a `TEST.md`**
|
||||
documenting test coverage, realistic workflows tested, and full test results output.
|
||||
|
||||
### Preview & Live Preview Norms
|
||||
|
||||
Preview support is optional at the harness level, but once a harness exposes
|
||||
meaningful intermediate visual or inspection state it MUST follow a consistent
|
||||
contract so agents and humans can reason about it across software.
|
||||
|
||||
See [`guides/preview-methodology.md`](guides/preview-methodology.md) for the
|
||||
detailed design guide and documentation checklist.
|
||||
|
||||
#### Producer vs consumer roles
|
||||
|
||||
Keep these roles separate:
|
||||
|
||||
- **Producer** — `cli-anything-<software> preview ...`
|
||||
- talks to the real backend
|
||||
- owns preview recipes, source fingerprinting, and live-session publishing
|
||||
- creates bundles, sessions, and trajectories
|
||||
- **Consumer** — `cli-hub previews ...`
|
||||
- reads already-published preview state
|
||||
- provides `inspect`, `html`, `watch`, and `open`
|
||||
- never renders, replays, or synthesizes preview artifacts
|
||||
|
||||
Harness help text, README examples, and SKILL examples MUST not present
|
||||
`cli-hub previews ...` as a render path. Agents should think in two steps:
|
||||
publish with the software CLI, inspect with `cli-hub`.
|
||||
|
||||
#### Three-layer persistence model
|
||||
|
||||
Preview-capable harnesses SHOULD treat preview state as three separate objects:
|
||||
|
||||
- **`bundle_dir`** — an immutable preview snapshot implementing
|
||||
`preview-bundle/v1`
|
||||
- **`session.json`** — the mutable current head for a live preview stream
|
||||
- **`trajectory.json`** — the append-only permanent command-to-preview history
|
||||
|
||||
Do not use `_bundle_dir` as the long-lived identity of a project. It is a
|
||||
single snapshot path and may change whenever the source fingerprint changes or a
|
||||
new bundle is forced. Stable live replay should anchor on `session_dir` plus
|
||||
`trajectory.json`.
|
||||
|
||||
#### Recommended CLI surface
|
||||
|
||||
If a harness has meaningful previewable state, it SHOULD expose at least:
|
||||
|
||||
- `preview recipes`
|
||||
- `preview capture`
|
||||
- `preview latest`
|
||||
|
||||
Add the following when the software semantics justify them:
|
||||
|
||||
- `preview diff`
|
||||
- `preview live start`
|
||||
- `preview live push`
|
||||
- `preview live status`
|
||||
- `preview live stop`
|
||||
- an internal or hidden poll monitor when poll-first refresh is supported
|
||||
|
||||
Recommended semantics:
|
||||
|
||||
- `preview capture` renders or exports a fresh bundle unless cache reuse is valid
|
||||
- `preview latest` returns the newest existing bundle and does not render a new one
|
||||
- `preview diff` publishes an immutable comparison bundle
|
||||
- `preview live start` creates a live session and publishes an initial bundle
|
||||
- `preview live push` adds a new bundle to an existing live session
|
||||
- `preview live status` is a read-only status probe
|
||||
- `preview live stop` stops further publishing without deleting prior history
|
||||
|
||||
#### Agent-facing JSON contract
|
||||
|
||||
All preview commands MUST support `--json`.
|
||||
|
||||
At minimum, machine-readable preview results SHOULD expose:
|
||||
|
||||
- stable paths such as `_bundle_dir`, `_manifest_path`, and `summary_path`
|
||||
- artifact metadata and relative artifact paths
|
||||
- session metadata when live mode is involved
|
||||
- trajectory references when history exists
|
||||
|
||||
`preview live status --json` is especially important because agents call it as a
|
||||
cheap introspection step between mutations. It SHOULD return:
|
||||
|
||||
- whether a live session exists and whether it is active
|
||||
- `session_dir`, `session_path`, and current bundle identifiers or paths
|
||||
- current publish reason and latest command metadata when available
|
||||
- a compact `trajectory_summary` with the latest command-to-bundle mapping
|
||||
- viewer hints such as inspect/html/watch/open commands when the harness emits them
|
||||
|
||||
The goal is to let an agent understand current live state without rereading the
|
||||
entire `trajectory.json` file.
|
||||
|
||||
#### Documentation requirements for preview-capable harnesses
|
||||
|
||||
Preview-capable harnesses SHOULD document preview usage in both `README.md` and
|
||||
`SKILL.md`. Cover:
|
||||
|
||||
- whether preview support exists at all
|
||||
- which modes exist: static, diff, live, poll
|
||||
- the producer commands agents should call
|
||||
- the consumer commands humans should call with `cli-hub previews ...`
|
||||
- whether the output is images, video, audio-derived stills, inspection JSON, or mixed artifacts
|
||||
- what makes the preview truthful for this software
|
||||
|
||||
Use concrete examples that show both the publish step and the inspect/watch
|
||||
step.
|
||||
|
||||
#### Truthfulness principle
|
||||
|
||||
Preview artifacts MUST be honest views of the real project or capture state.
|
||||
|
||||
- Prefer real backend render/export/inspection paths.
|
||||
- Use native offscreen export or replay when the software provides it.
|
||||
- Do not fake renders in Python.
|
||||
- Do not screen-scrape GUI windows just to create something image-like.
|
||||
- If a temporary camera, light, or helper rig must be injected, mark the bundle
|
||||
status accordingly and explain the limitation in summary/context output.
|
||||
|
||||
Truthful previews matter more than pretty previews. Agents need intermediate
|
||||
state they can trust when deciding what to do next.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
<software>/
|
||||
└── agent-harness/
|
||||
├── <SOFTWARE>.md # Project-specific analysis and SOP
|
||||
├── setup.py # PyPI package configuration (Phase 7)
|
||||
├── cli_anything/ # Namespace package (NO __init__.py here)
|
||||
│ └── <software>/ # Sub-package for this CLI
|
||||
│ ├── __init__.py
|
||||
│ ├── __main__.py # python3 -m cli_anything.<software>
|
||||
│ ├── README.md # HOW TO RUN — required
|
||||
│ ├── <software>_cli.py # Main CLI entry point (Click + REPL)
|
||||
│ ├── core/ # Core modules (one per domain)
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── project.py # Project create/open/save/info
|
||||
│ │ ├── ... # Domain-specific modules
|
||||
│ │ ├── export.py # Render pipeline + filter translation
|
||||
│ │ └── session.py # Stateful session, undo/redo
|
||||
│ ├── utils/ # Shared utilities
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── <software>_backend.py # Backend: invokes the real software
|
||||
│ │ └── repl_skin.py # Unified REPL skin (copy from plugin)
|
||||
│ └── tests/ # Test suites
|
||||
│ ├── TEST.md # Test documentation and results — required
|
||||
│ ├── test_core.py # Unit tests (synthetic data)
|
||||
│ └── test_full_e2e.py # E2E tests (real files)
|
||||
└── examples/ # Example scripts and workflows
|
||||
```
|
||||
|
||||
**Critical:** The `cli_anything/` directory must NOT contain an `__init__.py`.
|
||||
This is what makes it a PEP 420 namespace package — multiple separately-installed
|
||||
PyPI packages can each contribute a sub-package under `cli_anything/` without
|
||||
conflicting. For example, `cli-anything-gimp` adds `cli_anything/gimp/` and
|
||||
`cli-anything-blender` adds `cli_anything/blender/`, and both coexist in the
|
||||
same Python environment.
|
||||
|
||||
Note: This HARNESS.md is part of the cli-anything-plugin. Individual software directories reference this file — do NOT duplicate it.
|
||||
|
||||
## Applying This to Other Software
|
||||
|
||||
This same SOP applies to any GUI application:
|
||||
|
||||
| Software | Backend CLI | Native Format | System Package | How the CLI Uses It |
|
||||
|----------|-------------|---------------|----------------|-------------------|
|
||||
| LibreOffice | `libreoffice --headless` | .odt/.ods/.odp (ODF ZIP) | `apt install libreoffice` | Generate ODF → convert to PDF/DOCX/XLSX/PPTX |
|
||||
| Blender | `blender --background --python` | .blend-cli.json | `apt install blender` | Generate bpy script → Blender renders to PNG/MP4 |
|
||||
| GIMP | `gimp -i -b '(script-fu ...)'` | .xcf | `apt install gimp` | Script-Fu commands → GIMP processes & exports |
|
||||
| Inkscape | `inkscape --actions="..."` | .svg (XML) | `apt install inkscape` | Manipulate SVG → Inkscape exports to PNG/PDF |
|
||||
| Shotcut/Kdenlive | `melt` or `ffmpeg` | .mlt (XML) | `apt install melt ffmpeg` | Build MLT XML → melt/ffmpeg renders video |
|
||||
| Audacity | `sox` | .aup3 | `apt install sox` | Generate sox commands → sox processes audio |
|
||||
| OBS Studio | `obs-websocket` | scene.json | `apt install obs-studio` | WebSocket API → OBS captures/records |
|
||||
| Browser (DOMShell) | `npx @apireno/domshell` (MCP) | Accessibility Tree (virtual FS) | `npm install -g npx` (if needed) + Chrome ext | MCP SDK → DOMShell tools → filesystem navigation |
|
||||
|
||||
**The software is a required dependency, not optional.** The CLI generates valid
|
||||
intermediate files (ODF, MLT XML, bpy scripts, SVG) and hands them to the real
|
||||
software for rendering. This is what makes the CLI actually useful — it's a
|
||||
command-line interface TO the software, not a replacement for it.
|
||||
|
||||
The pattern is always the same: **build the data → call the real software → verify
|
||||
the output**.
|
||||
|
||||
## Guides Reference
|
||||
|
||||
Detailed guides live in `guides/`. Use this table to decide which ones to read
|
||||
based on the software you're building a harness for.
|
||||
|
||||
| Guide | Read when... | Phase |
|
||||
|-------|-------------|-------|
|
||||
| [`session-locking.md`](guides/session-locking.md) | Implementing session save (all harnesses) | Phase 3 |
|
||||
| [`preview-methodology.md`](guides/preview-methodology.md) | Designing preview, live preview, and agent-facing preview flows | Phase 3, 6.5 |
|
||||
| [`skill-generation.md`](guides/skill-generation.md) | Generating the SKILL.md file | Phase 6.5 |
|
||||
| [`pypi-publishing.md`](guides/pypi-publishing.md) | Packaging and installing the CLI | Phase 7 |
|
||||
| [`mcp-backend.md`](guides/mcp-backend.md) | Software has an MCP server, no native CLI | Phase 3 |
|
||||
| [`filter-translation.md`](guides/filter-translation.md) | Video/audio CLI with effects that need render-time translation | Phase 3 |
|
||||
| [`timecode-precision.md`](guides/timecode-precision.md) | Video/audio CLI with non-integer frame rates (29.97fps, etc.) | Phase 3, 5 |
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [2026] [HKUDS CLI-Anything Team]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,307 @@
|
||||
# Publishing the cli-anything Plugin
|
||||
|
||||
This guide explains how to make the cli-anything plugin installable and publish it.
|
||||
|
||||
## Option 1: Local Installation (Development)
|
||||
|
||||
### For Testing
|
||||
|
||||
1. **Copy to Claude Code plugins directory:**
|
||||
```bash
|
||||
cp -r /root/cli-anything/cli-anything-plugin ~/.claude/plugins/cli-anything
|
||||
```
|
||||
|
||||
2. **Reload plugins in Claude Code:**
|
||||
```bash
|
||||
/reload-plugins
|
||||
```
|
||||
|
||||
3. **Verify installation:**
|
||||
```bash
|
||||
/help cli-anything
|
||||
```
|
||||
|
||||
### For Sharing Locally
|
||||
|
||||
Package as a tarball:
|
||||
```bash
|
||||
cd /root/cli-anything
|
||||
tar -czf cli-anything-plugin-v1.0.0.tar.gz cli-anything-plugin/
|
||||
```
|
||||
|
||||
Others can install:
|
||||
```bash
|
||||
cd ~/.claude/plugins
|
||||
tar -xzf cli-anything-plugin-v1.0.0.tar.gz
|
||||
```
|
||||
|
||||
## Option 2: GitHub Repository (Recommended)
|
||||
|
||||
### 1. Create GitHub Repository
|
||||
|
||||
```bash
|
||||
cd /root/cli-anything/cli-anything-plugin
|
||||
|
||||
# Initialize git
|
||||
git init
|
||||
git add .
|
||||
git commit -m "Initial commit: cli-anything plugin v1.0.0"
|
||||
|
||||
# Create repo on GitHub (via web or gh CLI)
|
||||
gh repo create cli-anything-plugin --public --source=. --remote=origin
|
||||
|
||||
# Push
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
### 2. Create Release
|
||||
|
||||
```bash
|
||||
# Tag the release
|
||||
git tag -a v1.0.0 -m "Release v1.0.0: Initial release"
|
||||
git push origin v1.0.0
|
||||
|
||||
# Create GitHub release
|
||||
gh release create v1.0.0 \
|
||||
--title "cli-anything Plugin v1.0.0" \
|
||||
--notes "Initial release with 4 commands and complete 6-phase methodology"
|
||||
```
|
||||
|
||||
### 3. Install from GitHub
|
||||
|
||||
Users can install directly:
|
||||
```bash
|
||||
cd ~/.claude/plugins
|
||||
git clone https://github.com/yourusername/cli-anything-plugin.git
|
||||
```
|
||||
|
||||
Or via Claude Code (if you set up a plugin registry):
|
||||
```bash
|
||||
/plugin install cli-anything@github:yourusername/cli-anything-plugin
|
||||
```
|
||||
|
||||
## Option 3: Claude Plugin Directory (Official)
|
||||
|
||||
To publish to the official Claude Plugin Directory:
|
||||
|
||||
### 1. Prepare for Submission
|
||||
|
||||
Ensure your plugin meets requirements:
|
||||
- ✅ Complete `plugin.json` with all metadata
|
||||
- ✅ Comprehensive README.md
|
||||
- ✅ LICENSE file (MIT recommended)
|
||||
- ✅ All commands documented
|
||||
- ✅ No security vulnerabilities
|
||||
- ✅ Tested and working
|
||||
|
||||
### 2. Submit to External Plugins
|
||||
|
||||
1. **Fork the official repository:**
|
||||
```bash
|
||||
gh repo fork anthropics/claude-plugins-official
|
||||
```
|
||||
|
||||
2. **Add your plugin to external_plugins:**
|
||||
```bash
|
||||
cd claude-plugins-official
|
||||
mkdir -p external_plugins/cli-anything
|
||||
cp -r /root/cli-anything/cli-anything-plugin/* external_plugins/cli-anything/
|
||||
```
|
||||
|
||||
3. **Create pull request:**
|
||||
```bash
|
||||
git checkout -b add-cli-anything-plugin
|
||||
git add external_plugins/cli-anything
|
||||
git commit -m "Add cli-anything plugin to external plugins"
|
||||
git push origin add-cli-anything-plugin
|
||||
gh pr create --title "Add cli-anything plugin" \
|
||||
--body "Adds cli-anything plugin for building CLI harnesses for GUI applications"
|
||||
```
|
||||
|
||||
4. **Fill out submission form:**
|
||||
- Visit: https://forms.anthropic.com/claude-plugin-submission
|
||||
- Provide plugin details
|
||||
- Link to your PR
|
||||
|
||||
### 3. Review Process
|
||||
|
||||
Anthropic will review:
|
||||
- Code quality and security
|
||||
- Documentation completeness
|
||||
- Functionality and usefulness
|
||||
- Compliance with plugin standards
|
||||
|
||||
Approval typically takes 1-2 weeks.
|
||||
|
||||
### 4. After Approval
|
||||
|
||||
Users can install via:
|
||||
```bash
|
||||
/plugin install cli-anything@claude-plugin-directory
|
||||
```
|
||||
|
||||
## Option 4: NPM Package (Alternative)
|
||||
|
||||
If you want to distribute via npm:
|
||||
|
||||
### 1. Create package.json
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@yourusername/cli-anything-plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "Claude Code plugin for building CLI harnesses",
|
||||
"main": ".claude-plugin/plugin.json",
|
||||
"scripts": {
|
||||
"install": "bash scripts/setup-cli-anything.sh"
|
||||
},
|
||||
"keywords": ["claude-code", "plugin", "cli", "harness"],
|
||||
"author": "Your Name",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/yourusername/cli-anything-plugin.git"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Publish to npm
|
||||
|
||||
```bash
|
||||
npm login
|
||||
npm publish --access public
|
||||
```
|
||||
|
||||
### 3. Install via npm
|
||||
|
||||
```bash
|
||||
cd ~/.claude/plugins
|
||||
npm install @yourusername/cli-anything-plugin
|
||||
```
|
||||
|
||||
## Versioning
|
||||
|
||||
Follow semantic versioning (semver):
|
||||
- **Major** (1.0.0 → 2.0.0): Breaking changes
|
||||
- **Minor** (1.0.0 → 1.1.0): New features, backward compatible
|
||||
- **Patch** (1.0.0 → 1.0.1): Bug fixes
|
||||
|
||||
Update version in:
|
||||
- `.claude-plugin/plugin.json`
|
||||
- `README.md`
|
||||
- Git tags
|
||||
|
||||
## Distribution Checklist
|
||||
|
||||
Before publishing:
|
||||
|
||||
- [ ] All commands tested and working
|
||||
- [ ] README.md is comprehensive
|
||||
- [ ] LICENSE file included
|
||||
- [ ] plugin.json has correct metadata
|
||||
- [ ] No hardcoded paths or credentials
|
||||
- [ ] Scripts are executable (`chmod +x`)
|
||||
- [ ] Documentation is up to date
|
||||
- [ ] Version number is correct
|
||||
- [ ] Git repository is clean
|
||||
- [ ] Tests pass (if applicable)
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Updating the Plugin
|
||||
|
||||
1. Make changes
|
||||
2. Update version in `plugin.json`
|
||||
3. Update CHANGELOG.md
|
||||
4. Commit and tag:
|
||||
```bash
|
||||
git commit -am "Release v1.1.0: Add new features"
|
||||
git tag v1.1.0
|
||||
git push origin main --tags
|
||||
```
|
||||
5. Create GitHub release
|
||||
6. Notify users of update
|
||||
|
||||
### Deprecation
|
||||
|
||||
If deprecating:
|
||||
1. Mark as deprecated in `plugin.json`
|
||||
2. Update README with deprecation notice
|
||||
3. Provide migration path
|
||||
4. Keep available for 6 months minimum
|
||||
|
||||
## Support
|
||||
|
||||
### Documentation
|
||||
|
||||
- Keep README.md updated
|
||||
- Document breaking changes
|
||||
- Provide migration guides
|
||||
|
||||
### Issue Tracking
|
||||
|
||||
Use GitHub Issues for:
|
||||
- Bug reports
|
||||
- Feature requests
|
||||
- Questions
|
||||
|
||||
### Community
|
||||
|
||||
- Respond to issues promptly
|
||||
- Accept pull requests
|
||||
- Credit contributors
|
||||
|
||||
## Security
|
||||
|
||||
### Reporting Vulnerabilities
|
||||
|
||||
Create SECURITY.md:
|
||||
```markdown
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Email: security@yourdomain.com
|
||||
|
||||
Please do not open public issues for security vulnerabilities.
|
||||
```
|
||||
|
||||
### Best Practices
|
||||
|
||||
- No credentials in code
|
||||
- Validate all inputs
|
||||
- Use secure dependencies
|
||||
- Regular security audits
|
||||
|
||||
## Legal
|
||||
|
||||
### License
|
||||
|
||||
MIT License allows:
|
||||
- Commercial use
|
||||
- Modification
|
||||
- Distribution
|
||||
- Private use
|
||||
|
||||
Requires:
|
||||
- License and copyright notice
|
||||
|
||||
### Trademark
|
||||
|
||||
If using "Claude" or "Anthropic":
|
||||
- Follow brand guidelines
|
||||
- Don't imply official endorsement
|
||||
- Use "for Claude Code" not "Claude's plugin"
|
||||
|
||||
## Resources
|
||||
|
||||
- Claude Code Plugin Docs: https://code.claude.com/docs/en/plugins
|
||||
- Plugin Directory: https://github.com/anthropics/claude-plugins-official
|
||||
- Submission Form: https://forms.anthropic.com/claude-plugin-submission
|
||||
- Community: Claude Code Discord/Forum
|
||||
|
||||
## Questions?
|
||||
|
||||
- GitHub Issues: https://github.com/yourusername/cli-anything-plugin/issues
|
||||
- Email: your-email@example.com
|
||||
- Discord: Your Discord handle
|
||||
@@ -0,0 +1,242 @@
|
||||
# Quick Start Guide
|
||||
|
||||
Get started with the cli-anything plugin in 5 minutes.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Copy plugin to Claude Code plugins directory
|
||||
cp -r /root/cli-anything/cli-anything-plugin ~/.claude/plugins/cli-anything
|
||||
|
||||
# Reload plugins in Claude Code
|
||||
/reload-plugins
|
||||
|
||||
# Verify installation
|
||||
/help cli-anything
|
||||
```
|
||||
|
||||
## Your First CLI Harness
|
||||
|
||||
Let's build a CLI for a simple GUI application:
|
||||
|
||||
```bash
|
||||
# Build complete CLI harness for GIMP
|
||||
/cli-anything gimp
|
||||
```
|
||||
|
||||
This will:
|
||||
1. ✅ Analyze GIMP's architecture
|
||||
2. ✅ Design the CLI structure
|
||||
3. ✅ Implement all core modules
|
||||
4. ✅ Create test plan
|
||||
5. ✅ Write and run tests
|
||||
6. ✅ Document results
|
||||
7. ✅ Create setup.py and install to PATH
|
||||
|
||||
**Time:** ~10-15 minutes (depending on complexity)
|
||||
|
||||
**Output:** `/root/cli-anything/gimp/agent-harness/`
|
||||
|
||||
## Install the CLI
|
||||
|
||||
```bash
|
||||
# Install to system PATH
|
||||
cd /root/cli-anything/gimp/agent-harness
|
||||
pip install -e .
|
||||
|
||||
# Verify it's in PATH
|
||||
which cli-anything-gimp
|
||||
|
||||
# Use it from anywhere
|
||||
cli-anything-gimp --help
|
||||
```
|
||||
|
||||
## Test the CLI
|
||||
|
||||
```bash
|
||||
# Navigate to the CLI directory
|
||||
cd /root/cli-anything/gimp/agent-harness
|
||||
|
||||
# Run the CLI directly (if installed)
|
||||
cli-anything-gimp --help
|
||||
|
||||
# Or run as module
|
||||
python3 -m cli_anything.gimp.gimp_cli --help
|
||||
|
||||
# Try creating a project
|
||||
cli-anything-gimp project new --width 800 --height 600 -o test.json
|
||||
|
||||
# Enter REPL mode
|
||||
cli-anything-gimp repl
|
||||
```
|
||||
|
||||
## Run Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
/cli-anything:test gimp
|
||||
|
||||
# Or manually
|
||||
cd /root/cli-anything/gimp/agent-harness
|
||||
python3 -m pytest cli_anything/gimp/tests/ -v
|
||||
|
||||
# Force tests to use the installed command (recommended for validation)
|
||||
CLI_ANYTHING_FORCE_INSTALLED=1 python3 -m pytest cli_anything/gimp/tests/ -v -s
|
||||
# Output should show: [_resolve_cli] Using installed command: /path/to/cli-anything-gimp
|
||||
```
|
||||
|
||||
## Validate Quality
|
||||
|
||||
```bash
|
||||
# Check if CLI meets all standards
|
||||
/cli-anything:validate gimp
|
||||
```
|
||||
|
||||
## Build Another CLI
|
||||
|
||||
```bash
|
||||
# Build CLI for Blender (3D software)
|
||||
/cli-anything blender
|
||||
|
||||
# Build CLI for Inkscape (vector graphics)
|
||||
/cli-anything inkscape
|
||||
|
||||
# Build CLI for Audacity (audio editor)
|
||||
/cli-anything audacity
|
||||
```
|
||||
|
||||
## Refining an Existing CLI
|
||||
|
||||
After the initial build, use the refine command to expand coverage:
|
||||
|
||||
```bash
|
||||
# Broad refinement — agent finds gaps across all capabilities
|
||||
/cli-anything:refine /home/user/obs-studio
|
||||
|
||||
# Focused refinement — target a specific functionality area
|
||||
/cli-anything:refine /home/user/obs-studio "scene transitions and streaming profiles"
|
||||
```
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### Build and Test
|
||||
```bash
|
||||
/cli-anything /home/user/gimp
|
||||
/cli-anything:test /home/user/gimp
|
||||
```
|
||||
|
||||
### Build, Validate, Test, Install
|
||||
```bash
|
||||
/cli-anything /home/user/blender
|
||||
/cli-anything:validate /home/user/blender
|
||||
/cli-anything:test /home/user/blender
|
||||
cd /root/cli-anything/blender/agent-harness
|
||||
pip install -e .
|
||||
which cli-anything-blender
|
||||
```
|
||||
|
||||
### Refine After Initial Build
|
||||
```bash
|
||||
# Expand coverage with gap analysis
|
||||
/cli-anything:refine /home/user/inkscape
|
||||
|
||||
# Focus on a specific area
|
||||
/cli-anything:refine /home/user/inkscape "path boolean operations and clipping masks"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Plugin not found
|
||||
```bash
|
||||
# Check if plugin is in the right location
|
||||
ls ~/.claude/plugins/cli-anything
|
||||
|
||||
# Reload plugins
|
||||
/reload-plugins
|
||||
```
|
||||
|
||||
### Tests fail
|
||||
```bash
|
||||
# Check Python version (need 3.10+)
|
||||
python3 --version
|
||||
|
||||
# Install dependencies
|
||||
pip install click pytest pillow numpy
|
||||
|
||||
# Run validation
|
||||
/cli-anything:validate <software>
|
||||
```
|
||||
|
||||
### CLI doesn't work
|
||||
```bash
|
||||
# Check if all files were created
|
||||
ls /root/cli-anything/<software>/agent-harness/cli_anything/<software>/
|
||||
|
||||
# Verify Python can import
|
||||
cd /root/cli-anything/<software>/agent-harness
|
||||
python3 -c "import cli_anything.<software>"
|
||||
|
||||
# Check if installed to PATH
|
||||
which cli-anything-<software>
|
||||
|
||||
# Reinstall if needed
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
## Publishing to PyPI
|
||||
|
||||
Once your CLI is ready:
|
||||
|
||||
```bash
|
||||
cd /root/cli-anything/<software>/agent-harness
|
||||
|
||||
# Install build tools
|
||||
pip install build twine
|
||||
|
||||
# Build distribution packages
|
||||
python -m build
|
||||
|
||||
# Upload to PyPI (requires account)
|
||||
twine upload dist/*
|
||||
```
|
||||
|
||||
Users can then install (multiple CLIs coexist without conflicts):
|
||||
```bash
|
||||
pip install cli-anything-gimp cli-anything-blender
|
||||
cli-anything-gimp --help
|
||||
cli-anything-blender --help
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Read the full README:** `cat README.md`
|
||||
2. **Study an example:** Explore `/root/cli-anything/gimp/agent-harness/cli_anything/gimp/`
|
||||
3. **Read HARNESS.md:** Understand the methodology at `~/.claude/plugins/cli-anything/HARNESS.md`
|
||||
4. **Build your own:** Choose a GUI app and run `/cli-anything <app-name>`
|
||||
|
||||
## Tips
|
||||
|
||||
- Start with simpler applications (GIMP, Inkscape) before complex ones (Blender, LibreOffice)
|
||||
- Always run validation before considering a CLI complete
|
||||
- Read the generated TEST.md to understand what's tested
|
||||
- Use `--json` flag for machine-readable output
|
||||
- REPL mode is great for interactive exploration
|
||||
- Install CLIs to PATH for agent discoverability
|
||||
- Publish to PyPI to share with the community
|
||||
|
||||
## Help
|
||||
|
||||
```bash
|
||||
# Get help on any command
|
||||
/help cli-anything
|
||||
/help cli-anything:refine
|
||||
/help cli-anything:test
|
||||
/help cli-anything:validate
|
||||
|
||||
# Or read the command docs
|
||||
cat commands/cli-anything.md
|
||||
```
|
||||
|
||||
## Success!
|
||||
|
||||
You now have a powerful tool for building CLI harnesses for any GUI application. Happy building!
|
||||
@@ -0,0 +1,462 @@
|
||||
# cli-anything Plugin for Claude Code
|
||||
|
||||
Build powerful, stateful CLI interfaces for any GUI application using the cli-anything harness methodology.
|
||||
|
||||
## Overview
|
||||
|
||||
The cli-anything plugin automates the process of creating production-ready command-line interfaces for GUI applications. It follows a proven methodology that has successfully generated CLIs for GIMP, Blender, Inkscape, Audacity, LibreOffice, OBS Studio, and Kdenlive — with over 1,100 passing tests across all implementations.
|
||||
|
||||
## What It Does
|
||||
|
||||
This plugin transforms GUI applications into agent-usable CLIs by:
|
||||
|
||||
1. **Analyzing** the application's architecture and data model
|
||||
2. **Designing** a CLI that mirrors the GUI's functionality
|
||||
3. **Implementing** core modules with proper state management
|
||||
4. **Testing** with comprehensive unit and E2E test suites
|
||||
5. **Documenting** everything for maintainability
|
||||
|
||||
The result: A stateful CLI with REPL mode, JSON output, undo/redo, and full test coverage.
|
||||
|
||||
## Installation
|
||||
|
||||
### From Claude Code
|
||||
|
||||
```bash
|
||||
/plugin install cli-anything@your-registry
|
||||
```
|
||||
|
||||
### Manual Installation
|
||||
|
||||
1. Clone this repository to your Claude Code plugins directory:
|
||||
```bash
|
||||
cd ~/.claude/plugins
|
||||
git clone https://github.com/yourusername/cli-anything-plugin.git
|
||||
```
|
||||
|
||||
2. Reload plugins:
|
||||
```bash
|
||||
/reload-plugins
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.10+
|
||||
- `click` - CLI framework
|
||||
- `pytest` - Testing framework
|
||||
- HARNESS.md (included in this plugin at `~/.claude/plugins/cli-anything/HARNESS.md`)
|
||||
|
||||
**Windows note:** Claude Code runs shell commands via `bash`. On Windows, install Git for Windows (includes `bash` and
|
||||
`cygpath`) or use WSL; otherwise commands may fail with `cygpath: command not found`.
|
||||
|
||||
Install Python dependencies:
|
||||
```bash
|
||||
pip install click pytest
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### `/cli-anything <software-path-or-repo>`
|
||||
|
||||
Build a complete CLI harness for any software application. Accepts a local path to the software source code or a GitHub repository URL.
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
# Build from local source
|
||||
/cli-anything /home/user/gimp
|
||||
|
||||
# Build from a GitHub repo
|
||||
/cli-anything https://github.com/blender/blender
|
||||
```
|
||||
|
||||
This runs all phases:
|
||||
1. Source Acquisition (clone if GitHub URL)
|
||||
2. Codebase Analysis
|
||||
3. CLI Architecture Design
|
||||
4. Implementation
|
||||
5. Test Planning
|
||||
6. Test Implementation & Documentation
|
||||
7. SKILL.md Generation
|
||||
8. PyPI Publishing and Installation
|
||||
|
||||
### `/cli-anything:refine <software-path> [focus]`
|
||||
|
||||
Refine an existing CLI harness to expand coverage. Analyzes gaps between the software's full capabilities and what the current CLI covers, then iteratively adds new commands and tests.
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
# Broad refinement — agent finds gaps across all capabilities
|
||||
/cli-anything:refine /home/user/gimp
|
||||
|
||||
# Focused refinement — agent targets a specific functionality area
|
||||
/cli-anything:refine /home/user/shotcut "vid-in-vid and picture-in-picture compositing"
|
||||
/cli-anything:refine /home/user/blender "particle systems and physics simulation"
|
||||
```
|
||||
|
||||
### `/cli-anything:test <software-path-or-repo>`
|
||||
|
||||
Run tests for a CLI harness and update TEST.md with results.
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
# Run all tests for GIMP CLI
|
||||
/cli-anything:test /home/user/gimp
|
||||
|
||||
# Run tests for Blender from GitHub
|
||||
/cli-anything:test https://github.com/blender/blender
|
||||
```
|
||||
|
||||
### `/cli-anything:validate <software-path-or-repo>`
|
||||
|
||||
Validate a CLI harness against HARNESS.md standards and best practices.
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
# Validate GIMP CLI
|
||||
/cli-anything:validate /home/user/gimp
|
||||
|
||||
# Validate from GitHub repo
|
||||
/cli-anything:validate https://github.com/blender/blender
|
||||
```
|
||||
|
||||
### `/cli-anything:list [--path <directory>] [--depth <n>] [--json]`
|
||||
|
||||
List all available CLI-Anything tools, including both installed packages and generated directories.
|
||||
|
||||
**Options:**
|
||||
- `--path <directory>` - Directory to search (default: current directory)
|
||||
- `--depth <n>` - Maximum recursion depth (default: unlimited). Scans depths 0 through N.
|
||||
- `--json` - Output in JSON format
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
# List all tools in current directory (unlimited depth)
|
||||
/cli-anything:list
|
||||
|
||||
# List tools with depth limit
|
||||
/cli-anything:list --depth 2
|
||||
|
||||
# List tools with JSON output
|
||||
/cli-anything:list --json
|
||||
|
||||
# Search a specific directory with depth limit
|
||||
/cli-anything:list --path /projects/my-tools --depth 3
|
||||
```
|
||||
|
||||
**Output includes:**
|
||||
- Tool name
|
||||
- Status (installed/generated)
|
||||
- Version
|
||||
- Source path
|
||||
|
||||
## The cli-anything Methodology
|
||||
|
||||
### Phase 1: Codebase Analysis
|
||||
|
||||
Analyze the target application:
|
||||
- Backend engine (e.g., MLT, GEGL, bpy)
|
||||
- Data model (XML, JSON, binary)
|
||||
- Existing CLI tools
|
||||
- GUI-to-API mappings
|
||||
|
||||
**Output:** Software-specific SOP document (e.g., `GIMP.md`)
|
||||
|
||||
### Phase 2: CLI Architecture Design
|
||||
|
||||
Design the CLI structure:
|
||||
- Command groups matching app domains
|
||||
- State model (JSON project format)
|
||||
- Output formats (human + JSON)
|
||||
- Rendering pipeline
|
||||
|
||||
**Output:** Architecture documented in SOP
|
||||
|
||||
### Phase 3: Implementation
|
||||
|
||||
Build the CLI:
|
||||
- Core modules (project, session, export, etc.)
|
||||
- Click-based CLI with command groups
|
||||
- REPL mode as default with unified `ReplSkin` (copy `repl_skin.py` from plugin to `utils/`)
|
||||
- `--json` flag for machine-readable output
|
||||
- Global session state with undo/redo
|
||||
- `invoke_without_command=True` so bare `cli-anything-<software>` enters REPL
|
||||
|
||||
**Output:** Working CLI at `agent-harness/cli_anything/<software>/`
|
||||
|
||||
### Phase 4: Test Planning
|
||||
|
||||
Plan comprehensive tests:
|
||||
- Unit test plan (modules, functions, edge cases)
|
||||
- E2E test plan (workflows, file types, validations)
|
||||
- Realistic workflow scenarios
|
||||
|
||||
**Output:** `TEST.md` Part 1 (the plan)
|
||||
|
||||
### Phase 5: Test Implementation
|
||||
|
||||
Write the tests:
|
||||
- `test_core.py` - Unit tests (synthetic data)
|
||||
- `test_full_e2e.py` - E2E tests (real files)
|
||||
- Workflow tests (multi-step scenarios)
|
||||
- Output verification (pixel analysis, format validation)
|
||||
- `TestCLISubprocess` class using `_resolve_cli("cli-anything-<software>")` to
|
||||
test the installed command via subprocess (falls back to `python -m` in dev)
|
||||
|
||||
**Output:** Complete test suite
|
||||
|
||||
### Phase 6: Test Documentation
|
||||
|
||||
Run and document:
|
||||
- Execute `pytest -v --tb=no`
|
||||
- Append full results to `TEST.md`
|
||||
- Document coverage and gaps
|
||||
|
||||
**Output:** `TEST.md` Part 2 (the results)
|
||||
|
||||
### Phase 6.5: SKILL.md Generation
|
||||
|
||||
Generate AI-discoverable skill definition:
|
||||
- Extract CLI metadata using `skill_generator.py`
|
||||
- Generate SKILL.md with YAML frontmatter (name, description)
|
||||
- Include command groups, examples, and agent-specific guidance
|
||||
- Output canonical skill to `skills/cli-anything-<software>/SKILL.md`
|
||||
- Refresh package-local compatibility copy at `cli_anything/<software>/skills/SKILL.md`
|
||||
|
||||
**Output:** SKILL.md file for AI agent discovery
|
||||
|
||||
### Phase 7: PyPI Publishing and Installation
|
||||
|
||||
Package and install:
|
||||
- Create `setup.py` with `find_namespace_packages(include=["cli_anything.*"])`
|
||||
- Structure as namespace package: `cli_anything/<software>/` (no `__init__.py` in `cli_anything/`)
|
||||
- Configure console_scripts entry point for PATH installation
|
||||
- Test local installation: `pip install -e .`
|
||||
- Verify CLI is in PATH: `which cli-anything-<software>`
|
||||
|
||||
**Output:** Installable package ready for distribution
|
||||
|
||||
## Output Structure
|
||||
|
||||
```
|
||||
skills/
|
||||
└── cli-anything-<software>/
|
||||
└── SKILL.md # Canonical repo-root skill for npx skills discovery
|
||||
|
||||
<software>/
|
||||
└── agent-harness/
|
||||
├── <SOFTWARE>.md # Software-specific SOP
|
||||
├── setup.py # PyPI config (find_namespace_packages)
|
||||
└── cli_anything/ # Namespace package (NO __init__.py)
|
||||
└── <software>/ # Sub-package (HAS __init__.py)
|
||||
├── README.md # Installation and usage
|
||||
├── <software>_cli.py # Main CLI entry point
|
||||
├── __init__.py
|
||||
├── __main__.py # python -m cli_anything.<software>
|
||||
├── core/ # Core modules
|
||||
│ ├── __init__.py
|
||||
│ ├── project.py # Project management
|
||||
│ ├── session.py # Undo/redo
|
||||
│ ├── export.py # Rendering/export
|
||||
│ └── ... # Domain-specific modules
|
||||
├── utils/ # Utilities
|
||||
│ ├── __init__.py
|
||||
│ ├── repl_skin.py # Unified REPL skin (copy from plugin)
|
||||
│ └── ...
|
||||
└── tests/
|
||||
├── __init__.py
|
||||
├── TEST.md # Test plan + results
|
||||
├── test_core.py # Unit tests
|
||||
└── test_full_e2e.py # E2E tests
|
||||
```
|
||||
|
||||
All CLIs use PEP 420 namespace packages under `cli_anything.*`. The `cli_anything/`
|
||||
directory has NO `__init__.py`, allowing multiple separately-installed CLI packages
|
||||
to coexist in the same Python environment without conflicts.
|
||||
|
||||
## Success Stories
|
||||
|
||||
The cli-anything methodology has successfully built CLIs for:
|
||||
|
||||
| Software | Tests | Description |
|
||||
|----------|-------|-------------|
|
||||
| GIMP | 103 | Raster image editor (Pillow-based) |
|
||||
| Blender | 200 | 3D creation suite (bpy script generation) |
|
||||
| Inkscape | 197 | Vector graphics editor (SVG manipulation) |
|
||||
| Audacity | 154 | Audio editor (WAV processing) |
|
||||
| LibreOffice | 143 | Office suite (ODF ZIP/XML) |
|
||||
| OBS Studio | 153 | Streaming/recording (JSON scene collections) |
|
||||
| Kdenlive | 151 | Video editor (MLT XML) |
|
||||
| Shotcut | 144 | Video editor (MLT XML, ffmpeg) |
|
||||
| **Total** | **1,245** | All tests passing |
|
||||
|
||||
## Key Features
|
||||
|
||||
### Stateful Session Management
|
||||
- Undo/redo with deep-copy snapshots (50-level stack)
|
||||
- Project state persistence
|
||||
- History tracking
|
||||
|
||||
### Dual Output Modes
|
||||
- Human-readable (tables, colors)
|
||||
- Machine-readable (`--json` flag)
|
||||
|
||||
### REPL Mode
|
||||
- Default behavior when CLI is invoked without a subcommand
|
||||
- Unified `ReplSkin` with branded banner, colored prompts, and styled messages
|
||||
- Persistent command history via `prompt_toolkit`
|
||||
- Pre-built message helpers: `success()`, `error()`, `warning()`, `info()`, `status()`, `table()`, `progress()`
|
||||
- Software-specific accent colors with consistent cli-anything branding
|
||||
|
||||
### Comprehensive Testing
|
||||
- Unit tests (synthetic data, no external deps)
|
||||
- E2E tests (real files, full pipeline)
|
||||
- Workflow tests (multi-step scenarios)
|
||||
- CLI subprocess tests via `_resolve_cli()` against the installed command
|
||||
- Force-installed mode (`CLI_ANYTHING_FORCE_INSTALLED=1`) for release validation
|
||||
- Output verification (pixel/audio analysis)
|
||||
|
||||
### Complete Documentation
|
||||
- Installation guides
|
||||
- Command reference
|
||||
- Architecture analysis
|
||||
- Test plans and results
|
||||
|
||||
### SKILL.md Generation
|
||||
- Automatic skill definition generation for AI agent discovery
|
||||
- YAML frontmatter with name and description for triggering
|
||||
- Command groups and usage examples
|
||||
- Agent-specific guidance for programmatic usage
|
||||
- Canonical repo-root `skills/` layout for `npx skills` discovery
|
||||
- Follows skill-creator methodology
|
||||
|
||||
### PyPI Distribution
|
||||
- PEP 420 namespace packages under `cli_anything.*`
|
||||
- Unified package naming: `cli-anything-<software>`
|
||||
- Multiple CLIs coexist without conflicts in the same environment
|
||||
- Automatic PATH installation via console_scripts
|
||||
- Easy installation: `pip install cli-anything-<software>`
|
||||
- Agent-discoverable via `which cli-anything-<software>`
|
||||
|
||||
## Best Practices
|
||||
|
||||
### When to Use cli-anything
|
||||
|
||||
✅ **Good for:**
|
||||
- GUI applications with clear data models
|
||||
- Apps with existing CLI tools or APIs
|
||||
- Projects needing agent-usable interfaces
|
||||
- Automation and scripting workflows
|
||||
|
||||
❌ **Not ideal for:**
|
||||
- Apps with purely binary, undocumented formats
|
||||
- Real-time interactive applications
|
||||
- Apps requiring GPU/display access
|
||||
|
||||
### Tips for Success
|
||||
|
||||
1. **Start with analysis** - Understand the app's architecture before coding
|
||||
2. **Follow the phases** - Don't skip test planning
|
||||
3. **Test thoroughly** - Aim for 100% pass rate
|
||||
4. **Document everything** - Future you will thank you
|
||||
5. **Use the validation command** - Catch issues early
|
||||
6. **Generate SKILL.md** - Make CLIs discoverable by AI agents
|
||||
7. **Install to PATH** - Make CLIs discoverable by running Phase 7
|
||||
8. **Publish to PyPI** - Share your CLI with the community
|
||||
|
||||
## Installation and Distribution
|
||||
|
||||
After building a CLI with this plugin, you can:
|
||||
|
||||
### Install Locally
|
||||
```bash
|
||||
cd /root/cli-anything/<software>/agent-harness
|
||||
pip install -e .
|
||||
cli-anything-<software> --help
|
||||
```
|
||||
|
||||
### Publish to PyPI
|
||||
```bash
|
||||
pip install build twine
|
||||
python -m build
|
||||
twine upload dist/*
|
||||
```
|
||||
|
||||
### Users Install from PyPI
|
||||
```bash
|
||||
pip install cli-anything-<software>
|
||||
cli-anything-<software> --help # Available in PATH
|
||||
```
|
||||
|
||||
This makes CLIs discoverable by AI agents that can check `which cli-anything-<software>` to find available tools.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Tests fail after building
|
||||
|
||||
1. Check dependencies: `pip list | grep -E 'click|pytest'`
|
||||
2. Verify Python version: `python3 --version` (need 3.10+)
|
||||
3. Run validation: `/cli-anything:validate <software>`
|
||||
4. Check TEST.md for specific failures
|
||||
|
||||
### CLI not found
|
||||
|
||||
- Verify output directory: `ls -la /root/cli-anything/<software>/agent-harness/cli_anything/<software>/`
|
||||
- Check for errors in build phase
|
||||
- Try rebuilding: `/cli-anything <software-path>`
|
||||
|
||||
### Import errors
|
||||
|
||||
- Ensure `__init__.py` files exist in all packages
|
||||
- Check Python path: `echo $PYTHONPATH`
|
||||
- Verify directory structure matches expected layout
|
||||
|
||||
### CLI not in PATH after installation
|
||||
|
||||
- Verify installation: `pip show cli-anything-<software>`
|
||||
- Check entry points: `pip show -f cli-anything-<software> | grep console_scripts`
|
||||
- Reinstall: `pip uninstall cli-anything-<software> && pip install cli-anything-<software>`
|
||||
- Check PATH: `echo $PATH | grep -o '[^:]*bin'`
|
||||
|
||||
## Contributing
|
||||
|
||||
To add support for new software:
|
||||
|
||||
1. Clone the target application's repository
|
||||
2. Run `/cli-anything <software-name>`
|
||||
3. Review and refine the generated CLI
|
||||
4. Submit a PR with the new harness
|
||||
|
||||
## License
|
||||
|
||||
Apache License 2.0 - See LICENSE file for details
|
||||
|
||||
## Credits
|
||||
|
||||
Built on the cli-anything methodology developed through the creation of 8 production CLI harnesses with 1,245 passing tests.
|
||||
|
||||
Inspired by the ralph-loop plugin's iterative development approach.
|
||||
|
||||
## Support
|
||||
|
||||
- Documentation: See HARNESS.md in this plugin for the complete methodology
|
||||
- Issues: Report bugs or request features on GitHub
|
||||
- Examples: Check `/root/cli-anything/` for reference implementations
|
||||
|
||||
## Version History
|
||||
|
||||
### 1.1.0 (2026-03-12)
|
||||
- Added SKILL.md generation (Phase 6.5)
|
||||
- New `skill_generator.py` module for extracting CLI metadata
|
||||
- Jinja2-based `SKILL.md.template` for customizable skill definitions
|
||||
- SKILL.md files follow skill-creator methodology with YAML frontmatter
|
||||
- AI agents can discover and use generated CLIs via SKILL.md files
|
||||
|
||||
### 1.0.0 (2026-03-05)
|
||||
- Initial release
|
||||
- Support for 4 commands: cli-anything, refine, test, validate
|
||||
- Complete 7-phase methodology implementation
|
||||
- Comprehensive documentation
|
||||
- PyPI publishing support with namespace isolation
|
||||
- `_resolve_cli()` helper for subprocess tests against installed commands
|
||||
- `CLI_ANYTHING_FORCE_INSTALLED=1` env var for release validation
|
||||
- 8 reference implementations, 1,245 passing tests
|
||||
@@ -0,0 +1,147 @@
|
||||
# cli-anything Command
|
||||
|
||||
Build a complete, stateful CLI harness for any GUI application.
|
||||
|
||||
## CRITICAL: Read HARNESS.md First
|
||||
|
||||
**Before doing anything else, you MUST read `./HARNESS.md`.** It defines the complete methodology, architecture standards, and implementation patterns. Every phase below follows HARNESS.md. Do not improvise — follow the harness specification.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
/cli-anything <software-path-or-repo>
|
||||
```
|
||||
|
||||
## Arguments
|
||||
|
||||
- `<software-path-or-repo>` - **Required.** Either:
|
||||
- A **local path** to the software source code (e.g., `/home/user/gimp`, `./blender`)
|
||||
- A **GitHub repository URL** (e.g., `https://github.com/GNOME/gimp`, `github.com/blender/blender`)
|
||||
|
||||
If a GitHub URL is provided, the agent clones the repo locally first, then works on the local copy.
|
||||
|
||||
**Note:** Software names alone (e.g., "gimp") are NOT accepted. You must provide the actual source code path or repository URL so the agent can analyze the codebase.
|
||||
|
||||
## What This Command Does
|
||||
|
||||
This command implements the complete cli-anything methodology to build a production-ready CLI harness for any GUI application. **All phases follow the standards defined in HARNESS.md.**
|
||||
|
||||
### Phase 0: Source Acquisition
|
||||
- If `<software-path-or-repo>` is a GitHub URL, clone it to a local working directory
|
||||
- Verify the local path exists and contains source code
|
||||
- Derive the software name from the directory name (e.g., `/home/user/gimp` -> `gimp`)
|
||||
|
||||
### Phase 1: Codebase Analysis
|
||||
- Analyzes the local source code
|
||||
- Analyzes the backend engine and data model
|
||||
- Maps GUI actions to API calls
|
||||
- Identifies existing CLI tools
|
||||
- Documents the architecture
|
||||
|
||||
### Phase 2: CLI Architecture Design
|
||||
- Designs command groups matching the app's domains
|
||||
- Plans the state model and output formats
|
||||
- Creates the software-specific SOP document (e.g., GIMP.md)
|
||||
|
||||
### Phase 3: Implementation
|
||||
- Creates the directory structure: `agent-harness/cli_anything/<software>/core`, `utils`, `tests`
|
||||
- Implements core modules (project, session, export, etc.)
|
||||
- Builds the Click-based CLI with REPL support
|
||||
- Implements `--json` output mode for agent consumption
|
||||
- All imports use `cli_anything.<software>.*` namespace
|
||||
|
||||
### Phase 4: Test Planning
|
||||
- Creates `TEST.md` with comprehensive test plan
|
||||
- Plans unit tests for all core modules
|
||||
- Plans E2E tests with real files
|
||||
- Designs realistic workflow scenarios
|
||||
|
||||
### Phase 5: Test Implementation
|
||||
- Writes unit tests (`test_core.py`) - synthetic data, no external deps
|
||||
- Writes E2E tests (`test_full_e2e.py`) - real files, full pipeline
|
||||
- Implements workflow tests simulating real-world usage
|
||||
- Adds output verification (pixel analysis, format validation, etc.)
|
||||
- Adds `TestCLISubprocess` class with `_resolve_cli("cli-anything-<software>")`
|
||||
that tests the installed command via subprocess (no hardcoded paths or CWD)
|
||||
|
||||
### Phase 6: Test Documentation
|
||||
- Runs all tests with `pytest -v --tb=no`
|
||||
- Appends full test results to `TEST.md`
|
||||
- Documents test coverage and any gaps
|
||||
|
||||
### Phase 6.5: SKILL.md Generation
|
||||
- Extracts CLI metadata using `skill_generator.py`
|
||||
- Generates SKILL.md with YAML frontmatter and Markdown body
|
||||
- Includes command groups, examples, and agent-specific guidance
|
||||
- Outputs the canonical skill to `skills/cli-anything-<software>/SKILL.md` and refreshes the packaged compatibility copy at `cli_anything/<software>/skills/SKILL.md`
|
||||
- Makes the CLI discoverable and usable by AI agents
|
||||
|
||||
### Phase 7: PyPI Publishing and Installation
|
||||
- Creates `setup.py` with `find_namespace_packages(include=["cli_anything.*"])`
|
||||
- Package name: `cli-anything-<software>`, namespace: `cli_anything.<software>`
|
||||
- `cli_anything/` has NO `__init__.py` (PEP 420 namespace package)
|
||||
- Configures console_scripts entry point for PATH installation
|
||||
- Tests local installation with `pip install -e .`
|
||||
- Verifies CLI is available in PATH: `which cli-anything-<software>`
|
||||
|
||||
## Output Structure
|
||||
|
||||
```
|
||||
<software-name>/
|
||||
└── agent-harness/
|
||||
├── <SOFTWARE>.md # Software-specific SOP
|
||||
├── setup.py # PyPI package config (find_namespace_packages)
|
||||
└── cli_anything/ # Namespace package (NO __init__.py)
|
||||
└── <software>/ # Sub-package (HAS __init__.py)
|
||||
├── README.md # Installation and usage guide
|
||||
├── <software>_cli.py # Main CLI entry point
|
||||
├── core/ # Core modules
|
||||
│ ├── project.py
|
||||
│ ├── session.py
|
||||
│ ├── export.py
|
||||
│ └── ...
|
||||
├── utils/ # Utilities
|
||||
└── tests/
|
||||
├── TEST.md # Test plan and results
|
||||
├── test_core.py # Unit tests
|
||||
└── test_full_e2e.py # E2E tests
|
||||
```
|
||||
|
||||
Canonical repo-root skill output:
|
||||
|
||||
```
|
||||
skills/
|
||||
└── cli-anything-<software>/
|
||||
└── SKILL.md
|
||||
```
|
||||
|
||||
## Example
|
||||
|
||||
```bash
|
||||
# Build a CLI for GIMP from local source
|
||||
/cli-anything /home/user/gimp
|
||||
|
||||
# Build from a GitHub repo
|
||||
/cli-anything https://github.com/blender/blender
|
||||
```
|
||||
|
||||
## Auto-Save + --dry-run (Required for Session-Based CLIs)
|
||||
|
||||
**Session-based CLIs must auto-save after one-shot mutations.** Without this, one-shot commands silently lose changes because `save_session()` is never called before the process exits. A `--dry-run` flag must also be provided to suppress the save.
|
||||
|
||||
See [`guides/auto-save-dry-run.md`](../guides/auto-save-dry-run.md) for the full pattern, code examples, and when it applies.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
The command succeeds when:
|
||||
1. All core modules are implemented and functional
|
||||
2. CLI supports both one-shot commands and REPL mode
|
||||
3. `--json` output mode works for all commands
|
||||
4. All tests pass (100% pass rate)
|
||||
5. Subprocess tests use `_resolve_cli()` and pass with `CLI_ANYTHING_FORCE_INSTALLED=1`
|
||||
6. TEST.md contains both plan and results
|
||||
7. README.md documents installation and usage
|
||||
8. SKILL.md is generated with proper YAML frontmatter and command documentation
|
||||
9. setup.py is created and local installation works
|
||||
10. CLI is available in PATH as `cli-anything-<software>`
|
||||
11. **Session-based CLIs implement auto-save + `--dry-run`** (see [guide](../guides/auto-save-dry-run.md))
|
||||
@@ -0,0 +1,237 @@
|
||||
# cli-anything:list Command
|
||||
|
||||
List all available CLI-Anything tools (installed and generated).
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
/cli-anything:list [--path <directory>] [--depth <n>] [--json]
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
- `--path <directory>` - Directory to search for generated CLIs (default: current directory)
|
||||
- `--depth <n>` - Maximum recursion depth for scanning (default: unlimited). Use `0` for current directory only, `1` for one level deep, etc.
|
||||
- `--json` - Output in JSON format for machine parsing
|
||||
|
||||
## What This Command Does
|
||||
|
||||
Displays all CLI-Anything tools available in the system:
|
||||
|
||||
### 1. Installed CLIs
|
||||
|
||||
Uses `importlib.metadata` to find installed `cli-anything-*` packages:
|
||||
- Pattern: package name starts with `cli-anything-`
|
||||
- Extracts: software name, version, entry point
|
||||
|
||||
```python
|
||||
from importlib.metadata import distributions
|
||||
|
||||
installed = {}
|
||||
for dist in distributions():
|
||||
name = dist.metadata.get("Name", "")
|
||||
if name.startswith("cli-anything-"):
|
||||
software = name.replace("cli-anything-", "")
|
||||
version = dist.version
|
||||
# Find executable via entry points or shutil.which
|
||||
executable = shutil.which(f"cli-anything-{software}")
|
||||
installed[software] = {
|
||||
"status": "installed",
|
||||
"version": version,
|
||||
"executable": executable
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Generated CLIs
|
||||
|
||||
Uses `glob` to find local CLI directories:
|
||||
- Pattern: `**/agent-harness/cli_anything/*/__init__.py` (or depth-limited variant)
|
||||
- Extracts: software name, version (from setup.py), source path
|
||||
- Status: `generated`
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
import glob
|
||||
import re
|
||||
|
||||
search_path = args.get("path", ".")
|
||||
max_depth = args.get("depth", None) # None means unlimited
|
||||
generated = {}
|
||||
|
||||
def extract_version_from_setup(setup_path):
|
||||
"""Extract version from setup.py using regex."""
|
||||
try:
|
||||
content = Path(setup_path).read_text()
|
||||
match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content)
|
||||
return match.group(1) if match else None
|
||||
except:
|
||||
return None
|
||||
|
||||
def build_glob_patterns(base_path, depth):
|
||||
"""Build list of glob patterns for depths 0 through max_depth.
|
||||
|
||||
Returns multiple patterns so that --depth 2 finds tools at depth 0, 1, AND 2.
|
||||
"""
|
||||
base = Path(base_path)
|
||||
suffix = "agent-harness/cli_anything/*/__init__.py"
|
||||
|
||||
if depth is None:
|
||||
# Unlimited depth: use **
|
||||
return [str(base / "**" / suffix)]
|
||||
|
||||
# Generate patterns for all depths from 0 to max_depth
|
||||
patterns = []
|
||||
for d in range(depth + 1):
|
||||
if d == 0:
|
||||
# depth 0: look in current directory
|
||||
patterns.append(str(base / suffix))
|
||||
else:
|
||||
# depth N: look N levels deep
|
||||
prefix = "/".join(["*"] * d)
|
||||
patterns.append(str(base / prefix / suffix))
|
||||
return patterns
|
||||
|
||||
patterns = build_glob_patterns(search_path, max_depth)
|
||||
for pattern in patterns:
|
||||
for init_file in glob.glob(pattern, recursive=True):
|
||||
parts = Path(init_file).parts
|
||||
# Find cli_anything/<software> pattern
|
||||
for i, p in enumerate(parts):
|
||||
if p == "cli_anything" and i + 1 < len(parts):
|
||||
software = parts[i + 1]
|
||||
# Get agent-harness directory as source
|
||||
agent_harness_idx = parts.index("agent-harness") if "agent-harness" in parts else i - 1
|
||||
source = str(Path(*parts[:agent_harness_idx + 2])) # up to agent-harness
|
||||
# Extract version from setup.py (setup.py is in agent-harness/, not cli_anything/)
|
||||
setup_path = Path(*parts[:agent_harness_idx + 1]) / "setup.py"
|
||||
version = extract_version_from_setup(setup_path)
|
||||
generated[software] = {
|
||||
"status": "generated",
|
||||
"version": version,
|
||||
"executable": None,
|
||||
"source": source
|
||||
}
|
||||
break
|
||||
```
|
||||
|
||||
### 3. Merge Results
|
||||
|
||||
- Deduplicate by software name
|
||||
- If both installed and generated: show `installed` status with both paths
|
||||
- The `source` field shows where the generated code is (even for installed)
|
||||
|
||||
## Output Formats
|
||||
|
||||
### Table Format (default)
|
||||
|
||||
```
|
||||
CLI-Anything Tools (found 5)
|
||||
|
||||
Name Status Version Source
|
||||
──────────────────────────────────────────────────────────────
|
||||
gimp installed 1.0.0 ./gimp/agent-harness
|
||||
blender installed 1.0.0 ./blender/agent-harness
|
||||
inkscape generated 1.0.0 ./inkscape/agent-harness
|
||||
audacity generated 1.0.0 ./audacity/agent-harness
|
||||
libreoffice generated 1.0.0 ./libreoffice/agent-harness
|
||||
```
|
||||
|
||||
### JSON Format (--json)
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": [
|
||||
{
|
||||
"name": "gimp",
|
||||
"status": "installed",
|
||||
"version": "1.0.0",
|
||||
"executable": "/usr/local/bin/cli-anything-gimp",
|
||||
"source": "./gimp/agent-harness"
|
||||
},
|
||||
{
|
||||
"name": "inkscape",
|
||||
"status": "generated",
|
||||
"version": "1.0.0",
|
||||
"executable": null,
|
||||
"source": "./inkscape/agent-harness"
|
||||
}
|
||||
],
|
||||
"total": 2,
|
||||
"installed": 1,
|
||||
"generated_only": 1
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Scenario | Action |
|
||||
|----------|--------|
|
||||
| No CLIs found | Show "No CLI-Anything tools found" message |
|
||||
| Invalid --path | Show error: "Path not found: <path>" |
|
||||
| Permission denied | Skip directory, continue scanning, show warning |
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
When this command is invoked, the agent should:
|
||||
|
||||
1. **Parse arguments**
|
||||
- Extract `--path` value (default: `.`)
|
||||
- Extract `--depth` value (default: `None` for unlimited recursion)
|
||||
- Extract `--json` flag (default: false)
|
||||
|
||||
2. **Validate path exists**
|
||||
- If `--path` specified and doesn't exist, show error and exit
|
||||
|
||||
3. **Scan installed CLIs**
|
||||
- Use `importlib.metadata.distributions()` to find all packages
|
||||
- Filter for packages starting with `cli-anything-`
|
||||
- Extract name, version, find executable path
|
||||
|
||||
4. **Scan generated CLIs**
|
||||
- Build glob pattern based on depth parameter
|
||||
- Use `glob.glob(pattern, recursive=True)`
|
||||
- Parse directory structure to extract software name
|
||||
- Calculate relative path from current directory
|
||||
|
||||
5. **Merge results**
|
||||
- Create dict keyed by software name
|
||||
- Prefer installed data when both exist
|
||||
- Keep source path from generated if available
|
||||
|
||||
6. **Format output**
|
||||
- If `--json`: output JSON to stdout
|
||||
- Otherwise: format as table with proper alignment
|
||||
|
||||
7. **Print results**
|
||||
- Show summary line with count
|
||||
- Show table or JSON
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# List all tools in current directory (unlimited depth)
|
||||
/cli-anything:list
|
||||
|
||||
# List tools with depth limit (only scan 2 levels deep)
|
||||
/cli-anything:list --depth 2
|
||||
|
||||
# List tools in current directory only (no recursion)
|
||||
/cli-anything:list --depth 0
|
||||
|
||||
# List tools with JSON output
|
||||
/cli-anything:list --json
|
||||
|
||||
# Search a specific directory with depth limit
|
||||
/cli-anything:list --path /projects/my-tools --depth 3
|
||||
|
||||
# Combined
|
||||
/cli-anything:list --path ./output --depth 2 --json
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- `--depth` controls how many directory levels to descend from the search path
|
||||
- Default depth is unlimited (`**` glob pattern)
|
||||
- CLI-Anything tools typically need at least 3-4 levels to find `agent-harness/cli_anything/software/__init__.py`
|
||||
- Relative paths are preferred for readability
|
||||
- The command should work without any external dependencies beyond Python stdlib
|
||||
@@ -0,0 +1,104 @@
|
||||
# cli-anything:refine Command
|
||||
|
||||
Refine an existing CLI harness to improve coverage of the software's functions and usage patterns.
|
||||
|
||||
## CRITICAL: Read HARNESS.md First
|
||||
|
||||
**Before refining, read `./HARNESS.md`.** All new commands and tests must follow the same standards as the original build. HARNESS.md is the single source of truth for architecture, patterns, and quality requirements.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
/cli-anything:refine <software-path> [focus]
|
||||
```
|
||||
|
||||
## Arguments
|
||||
|
||||
- `<software-path>` - **Required.** Local path to the software source code (e.g., `/home/user/gimp`, `./blender`). Must be the same source tree used during the original build.
|
||||
|
||||
**Note:** Only local paths are accepted. If you need to work from a GitHub repo, clone it first with `/cli-anything`, then refine.
|
||||
|
||||
- `[focus]` - **Optional.** A natural-language description of the functionality area to focus on. When provided, the agent skips broad gap analysis and instead targets the specified capability area.
|
||||
|
||||
Examples:
|
||||
- `/cli-anything:refine /home/user/shotcut "vid-in-vid and picture-in-picture features"`
|
||||
- `/cli-anything:refine /home/user/gimp "all batch processing and scripting filters"`
|
||||
- `/cli-anything:refine /home/user/blender "particle systems and physics simulation"`
|
||||
- `/cli-anything:refine /home/user/inkscape "path boolean operations and clipping"`
|
||||
|
||||
When `[focus]` is provided:
|
||||
- Step 2 (Analyze Software Capabilities) narrows to only the specified area
|
||||
- Step 3 (Gap Analysis) compares only the focused capabilities against current coverage
|
||||
- The agent should still present findings before implementing, but scoped to the focus area
|
||||
|
||||
## What This Command Does
|
||||
|
||||
This command is used **after** a CLI harness has already been built with `/cli-anything`. It analyzes gaps between the software's full capabilities and what the current CLI covers, then iteratively expands coverage. If a `[focus]` is given, the agent narrows its analysis and implementation to that specific functionality area.
|
||||
|
||||
### Step 1: Inventory Current Coverage
|
||||
- Read the existing CLI entry point (`<software>_cli.py`) and all core modules
|
||||
- List every command, subcommand, and option currently implemented
|
||||
- Read the existing test suite to understand what's tested
|
||||
- Build a coverage map: `{ function_name: covered | not_covered }`
|
||||
|
||||
### Step 2: Analyze Software Capabilities
|
||||
- Re-scan the software source at `<software-path>`
|
||||
- Identify all public APIs, CLI tools, scripting interfaces, and batch-mode operations
|
||||
- Focus on functions that produce observable output (renders, exports, transforms, conversions)
|
||||
- Categorize by domain (e.g., for GIMP: filters, color adjustments, layer ops, selection tools)
|
||||
|
||||
### Step 3: Gap Analysis
|
||||
- Compare current CLI coverage against the software's full capability set
|
||||
- Prioritize gaps by:
|
||||
1. **High impact** — commonly used functions missing from the CLI
|
||||
2. **Easy wins** — functions with simple APIs that can be wrapped quickly
|
||||
3. **Composability** — functions that unlock new workflows when combined with existing commands
|
||||
- Present the gap report to the user and confirm which gaps to address
|
||||
|
||||
### Step 4: Implement New Commands
|
||||
- Add new commands/subcommands to the CLI for the selected gaps
|
||||
- Follow the same patterns as existing commands (as defined in HARNESS.md):
|
||||
- Click command groups
|
||||
- `--json` output support
|
||||
- Session state integration
|
||||
- Error handling with `handle_error`
|
||||
- Add corresponding core module functions in `core/` or `utils/`
|
||||
|
||||
### Step 5: Expand Tests
|
||||
- Add unit tests for every new function in `test_core.py`
|
||||
- Add E2E tests for new commands in `test_full_e2e.py`
|
||||
- Add workflow tests that combine new commands with existing ones
|
||||
- Run all tests (old + new) to ensure no regressions
|
||||
|
||||
### Step 6: Update Documentation
|
||||
- Update `README.md` with new commands and usage examples
|
||||
- Update `TEST.md` with new test results
|
||||
- Update the SOP document (`<SOFTWARE>.md`) with new coverage notes
|
||||
|
||||
## Example
|
||||
|
||||
```bash
|
||||
# Broad refinement — agent finds gaps across all capabilities
|
||||
/cli-anything:refine /home/user/gimp
|
||||
|
||||
# Focused refinement — agent targets a specific functionality area
|
||||
/cli-anything:refine /home/user/shotcut "vid-in-vid and picture-in-picture compositing"
|
||||
/cli-anything:refine /home/user/gimp "batch processing and Script-Fu filters"
|
||||
/cli-anything:refine /home/user/blender "particle systems and physics simulation"
|
||||
/cli-anything:refine /home/user/inkscape "path boolean operations and clipping masks"
|
||||
```
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- All existing tests still pass (no regressions)
|
||||
- New commands follow the same architectural patterns (per HARNESS.md)
|
||||
- New tests achieve 100% pass rate
|
||||
- Coverage meaningfully improved (new functions exposed via CLI)
|
||||
- Documentation updated to reflect changes
|
||||
|
||||
## Notes
|
||||
|
||||
- Refine is incremental — run it multiple times to steadily expand coverage
|
||||
- Each run should focus on a coherent set of related functions rather than trying to cover everything at once
|
||||
- The agent should present the gap analysis before implementing, so the user can steer priorities
|
||||
- Refine never removes existing commands — it only adds or enhances
|
||||
@@ -0,0 +1,73 @@
|
||||
# cli-anything:test Command
|
||||
|
||||
Run tests for a CLI harness and update TEST.md with results.
|
||||
|
||||
## CRITICAL: Read HARNESS.md First
|
||||
|
||||
**Before running tests, read `./HARNESS.md`.** It defines the test standards, expected structure, and what constitutes a passing test suite.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
/cli-anything:test <software-path-or-repo>
|
||||
```
|
||||
|
||||
## Arguments
|
||||
|
||||
- `<software-path-or-repo>` - **Required.** Either:
|
||||
- A **local path** to the software source code (e.g., `/home/user/gimp`, `./blender`)
|
||||
- A **GitHub repository URL** (e.g., `https://github.com/GNOME/gimp`, `github.com/blender/blender`)
|
||||
|
||||
If a GitHub URL is provided, the agent clones the repo locally first, then works on the local copy.
|
||||
|
||||
The software name is derived from the directory name. The agent locates the CLI harness at `/root/cli-anything/<software-name>/agent-harness/`.
|
||||
|
||||
## What This Command Does
|
||||
|
||||
1. **Locates the CLI** - Finds the CLI harness based on the software path
|
||||
2. **Runs pytest** - Executes tests with `-v -s --tb=short`
|
||||
3. **Captures output** - Saves full test results
|
||||
4. **Verifies subprocess backend** - Confirms `[_resolve_cli] Using installed command:` appears in output
|
||||
5. **Updates TEST.md** - Appends results to the Test Results section
|
||||
6. **Reports status** - Shows pass/fail summary
|
||||
|
||||
## Test Output Format
|
||||
|
||||
The command appends to TEST.md:
|
||||
|
||||
```markdown
|
||||
## Test Results
|
||||
|
||||
Last run: 2024-03-05 14:30:00
|
||||
|
||||
```
|
||||
[full pytest -v --tb=no output]
|
||||
```
|
||||
|
||||
**Summary**: 103 passed in 3.05s
|
||||
```
|
||||
|
||||
## Example
|
||||
|
||||
```bash
|
||||
# Run all tests for GIMP CLI
|
||||
/cli-anything:test /home/user/gimp
|
||||
|
||||
# Run tests for Blender from GitHub
|
||||
/cli-anything:test https://github.com/blender/blender
|
||||
```
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- All tests pass (100% pass rate)
|
||||
- TEST.md is updated with full results
|
||||
- No test failures or errors
|
||||
- `[_resolve_cli]` output confirms installed command path
|
||||
|
||||
## Failure Handling
|
||||
|
||||
If tests fail:
|
||||
1. Shows which tests failed
|
||||
2. Does NOT update TEST.md (keeps previous passing results)
|
||||
3. Suggests fixes based on error messages
|
||||
4. Offers to re-run after fixes
|
||||
@@ -0,0 +1,123 @@
|
||||
# cli-anything:validate Command
|
||||
|
||||
Validate a CLI harness against HARNESS.md standards and best practices.
|
||||
|
||||
## CRITICAL: Read HARNESS.md First
|
||||
|
||||
**Before validating, read `./HARNESS.md`.** It is the single source of truth for all validation checks below. Every check in this command maps to a requirement in HARNESS.md.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
/cli-anything:validate <software-path-or-repo>
|
||||
```
|
||||
|
||||
## Arguments
|
||||
|
||||
- `<software-path-or-repo>` - **Required.** Either:
|
||||
- A **local path** to the software source code (e.g., `/home/user/gimp`, `./blender`)
|
||||
- A **GitHub repository URL** (e.g., `https://github.com/GNOME/gimp`, `github.com/blender/blender`)
|
||||
|
||||
If a GitHub URL is provided, the agent clones the repo locally first, then works on the local copy.
|
||||
|
||||
The software name is derived from the directory name. The agent locates the CLI harness at `/root/cli-anything/<software-name>/agent-harness/`.
|
||||
|
||||
## What This Command Validates
|
||||
|
||||
### 1. Directory Structure
|
||||
- `agent-harness/cli_anything/<software>/` exists (namespace sub-package)
|
||||
- `cli_anything/` has NO `__init__.py` (PEP 420 namespace package)
|
||||
- `<software>/` HAS `__init__.py` (regular sub-package)
|
||||
- `core/`, `utils/`, `tests/` subdirectories present
|
||||
- `setup.py` in agent-harness/ uses `find_namespace_packages`
|
||||
|
||||
### 2. Required Files
|
||||
- `README.md` - Installation and usage guide
|
||||
- `<software>_cli.py` - Main CLI entry point
|
||||
- `core/project.py` - Project management
|
||||
- `core/session.py` - Undo/redo
|
||||
- `core/export.py` - Rendering/export
|
||||
- `tests/TEST.md` - Test plan and results
|
||||
- `tests/test_core.py` - Unit tests
|
||||
- `tests/test_full_e2e.py` - E2E tests
|
||||
- `../<SOFTWARE>.md` - Software-specific SOP
|
||||
|
||||
### 3. CLI Implementation Standards
|
||||
- Uses Click framework
|
||||
- Has command groups (not flat commands)
|
||||
- Implements `--json` flag for machine-readable output
|
||||
- Implements `--project` flag for project file
|
||||
- Has `handle_error` decorator for consistent error handling
|
||||
- Has REPL mode
|
||||
- Has global session state
|
||||
|
||||
### 4. Core Module Standards
|
||||
- `project.py` has: create, open, save, info, list_profiles
|
||||
- `session.py` has: Session class with undo/redo/snapshot
|
||||
- `export.py` has: render function and EXPORT_PRESETS
|
||||
- All modules have proper docstrings
|
||||
- All functions have type hints
|
||||
|
||||
### 5. Test Standards
|
||||
- `TEST.md` has both plan (Part 1) and results (Part 2)
|
||||
- Unit tests use synthetic data only
|
||||
- E2E tests use real files
|
||||
- Workflow tests simulate real-world scenarios
|
||||
- `test_full_e2e.py` has a `TestCLISubprocess` class
|
||||
- `TestCLISubprocess` uses `_resolve_cli("cli-anything-<software>")` (no hardcoded paths)
|
||||
- `_resolve_cli` prints which backend is used and supports `CLI_ANYTHING_FORCE_INSTALLED`
|
||||
- Subprocess `_run` does NOT set `cwd` (installed commands work from any directory)
|
||||
- All tests pass (100% pass rate)
|
||||
|
||||
### 6. Documentation Standards
|
||||
- `README.md` has: installation, usage, command reference, examples
|
||||
- `<SOFTWARE>.md` has: architecture analysis, command map, rendering gap assessment
|
||||
- No duplicate `HARNESS.md` (should reference plugin's HARNESS.md)
|
||||
- All commands documented with examples
|
||||
|
||||
### 7. PyPI Packaging Standards
|
||||
- `setup.py` uses `find_namespace_packages(include=["cli_anything.*"])`
|
||||
- Package name follows `cli-anything-<software>` convention
|
||||
- Entry point: `cli-anything-<software>=cli_anything.<software>.<software>_cli:main`
|
||||
- `cli_anything/` has NO `__init__.py` (namespace package rule)
|
||||
- All imports use `cli_anything.<software>.*` prefix
|
||||
- Dependencies listed in install_requires
|
||||
- Python version requirement specified (>=3.10)
|
||||
|
||||
### 8. Code Quality
|
||||
- No syntax errors
|
||||
- No import errors
|
||||
- Follows PEP 8 style
|
||||
- No hardcoded paths (uses relative paths or config)
|
||||
- Proper error handling (no bare `except:`)
|
||||
|
||||
## Validation Report
|
||||
|
||||
The command generates a detailed report:
|
||||
|
||||
```
|
||||
CLI Harness Validation Report
|
||||
Software: gimp
|
||||
Path: /root/cli-anything/gimp/agent-harness/cli_anything/gimp
|
||||
|
||||
Directory Structure (5/5 checks passed)
|
||||
Required Files (9/9 files present)
|
||||
CLI Implementation (7/7 standards met)
|
||||
Core Modules (5/5 standards met)
|
||||
Test Standards (10/10 standards met)
|
||||
Documentation (4/4 standards met)
|
||||
PyPI Packaging (7/7 standards met)
|
||||
Code Quality (5/5 checks passed)
|
||||
|
||||
Overall: PASS (52/52 checks)
|
||||
```
|
||||
|
||||
## Example
|
||||
|
||||
```bash
|
||||
# Validate GIMP CLI
|
||||
/cli-anything:validate /home/user/gimp
|
||||
|
||||
# Validate from GitHub repo
|
||||
/cli-anything:validate https://github.com/blender/blender
|
||||
```
|
||||
@@ -0,0 +1,87 @@
|
||||
# Auto-Save + --dry-run for One-Shot Commands
|
||||
|
||||
Session-based CLIs must auto-save after one-shot mutations and support `--dry-run` to suppress it.
|
||||
|
||||
## Problem
|
||||
|
||||
One-shot commands like `cli-anything-kdenlive --project p.json bin import video.mp4` mutate the in-memory project but never call `save_session()`. The project file on disk is unchanged when the process exits — changes are silently lost.
|
||||
|
||||
## Solution
|
||||
|
||||
Two additions to `<software>_cli.py`:
|
||||
|
||||
### 1. Add `--dry-run` to the main CLI group
|
||||
|
||||
```python
|
||||
@click.group(invoke_without_command=True)
|
||||
@click.option("--json", "use_json", is_flag=True, help="Output as JSON")
|
||||
@click.option("--project", "project_path", type=str, default=None,
|
||||
help="Path to project file")
|
||||
@click.option("--dry-run", "dry_run", is_flag=True, default=False,
|
||||
help="Run command without saving changes to disk")
|
||||
@click.pass_context
|
||||
def cli(ctx, use_json, project_path, dry_run):
|
||||
...
|
||||
```
|
||||
|
||||
### 2. Add `@cli.result_callback()` after the group
|
||||
|
||||
```python
|
||||
@cli.result_callback()
|
||||
def auto_save_on_exit(result, use_json, project_path, dry_run, **kwargs):
|
||||
"""Auto-save project after one-shot commands if state was modified."""
|
||||
if _repl_mode:
|
||||
return
|
||||
if dry_run:
|
||||
return
|
||||
sess = get_session()
|
||||
if sess.has_project() and sess._modified and sess.project_path:
|
||||
try:
|
||||
sess.save_session()
|
||||
except Exception as e:
|
||||
click.echo(f"Warning: Auto-save failed: {e}", err=True)
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
- `result_callback` fires once after the CLI group's command chain completes
|
||||
- Checks `_repl_mode` (skip in REPL — user saves manually), `dry_run` (skip if set), and `sess._modified` (skip if nothing changed)
|
||||
- Calls `sess.save_session()` which uses atomic `_locked_save_json` (see [`session-locking.md`](session-locking.md))
|
||||
- Does NOT fire if `sys.exit(1)` was called in `handle_error` (error path) — correct behavior
|
||||
|
||||
### Alternative: `ctx.call_on_close` pattern
|
||||
|
||||
For harnesses that open the session inline (not via a global singleton), use a closure instead:
|
||||
|
||||
```python
|
||||
def cli(ctx, use_json, project_path, dry_run):
|
||||
...
|
||||
if project_path:
|
||||
sess = get_session()
|
||||
proj = proj_mod.open_project(project_path)
|
||||
sess.set_project(proj, project_path)
|
||||
|
||||
def _auto_save():
|
||||
if dry_run:
|
||||
return
|
||||
if sess._modified and sess.project_path and not _repl_mode:
|
||||
sess.save_session()
|
||||
|
||||
ctx.call_on_close(_auto_save)
|
||||
```
|
||||
|
||||
## `--dry-run` semantics
|
||||
|
||||
| Mode | Behavior |
|
||||
|------|----------|
|
||||
| One-shot (default) | Command executes, output is printed, project is auto-saved |
|
||||
| One-shot + `--dry-run` | Command executes, output is printed, project is **not** saved |
|
||||
| REPL | `--dry-run` is accepted but ignored (REPL never auto-saves) |
|
||||
|
||||
## When this applies
|
||||
|
||||
**Required** for any harness where:
|
||||
- `core/session.py` exists with `save_session()` and `_modified` tracking
|
||||
- The CLI accepts a `--project` flag to load a file-backed project
|
||||
- Commands call `sess.snapshot()` before mutations
|
||||
|
||||
**Does not apply** to stateless API clients, service wrappers, or harnesses without a persistent project file.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Filter Translation Pitfalls
|
||||
|
||||
When translating effects between formats (e.g., MLT → ffmpeg), watch for these common issues.
|
||||
|
||||
## Duplicate Filter Types
|
||||
|
||||
Some tools (ffmpeg) don't allow the same filter twice in a chain. If your project has both `brightness` and `saturation` filters, and both map to ffmpeg's `eq=`, you must **merge** them into a single `eq=brightness=X:saturation=Y`.
|
||||
|
||||
## Ordering Constraints
|
||||
|
||||
ffmpeg's `concat` filter requires **interleaved** stream ordering:
|
||||
`[v0][a0][v1][a1][v2][a2]`, NOT grouped `[v0][v1][v2][a0][a1][a2]`.
|
||||
|
||||
The error message ("media type mismatch") is cryptic if you don't know this.
|
||||
|
||||
## Parameter Space Differences
|
||||
|
||||
Effect parameters often use different scales:
|
||||
- MLT brightness `1.15` = +15%
|
||||
- ffmpeg `eq=brightness=0.06` on a -1..1 scale
|
||||
|
||||
Document every mapping explicitly.
|
||||
|
||||
## Unmappable Effects
|
||||
|
||||
Some effects have no equivalent in the render tool. Handle gracefully (warn, skip) rather than crash.
|
||||
@@ -0,0 +1,64 @@
|
||||
# MCP Backend Pattern
|
||||
|
||||
For services that expose an MCP (Model Context Protocol) server instead of a traditional CLI.
|
||||
|
||||
## When to Use
|
||||
|
||||
- The software has an official or community MCP server
|
||||
- No native CLI exists, or MCP provides better functionality
|
||||
- You want to integrate AI/agent tools that speak MCP protocol
|
||||
|
||||
**Use case:** When the software provides an MCP server instead of a traditional CLI.
|
||||
Example: DOMShell provides browser automation via MCP tools.
|
||||
|
||||
## Backend Wrapper (`utils/<service>_backend.py`)
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from typing import Any
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
|
||||
async def _call_tool(tool_name: str, arguments: dict) -> Any:
|
||||
"""Call an MCP tool."""
|
||||
server_params = StdioServerParameters(
|
||||
command="npx",
|
||||
args=["@apireno/domshell"]
|
||||
)
|
||||
async with stdio_client(server_params) as (read, write):
|
||||
async with ClientSession(read, write) as session:
|
||||
await session.initialize()
|
||||
result = await session.call_tool(tool_name, arguments)
|
||||
return result
|
||||
|
||||
def is_available() -> bool:
|
||||
"""Check if MCP server is available."""
|
||||
# Try to spawn and verify
|
||||
...
|
||||
|
||||
# Sync wrappers for each tool
|
||||
def ls(path: str = "/") -> dict:
|
||||
"""List directory contents."""
|
||||
return asyncio.run(_call_tool("domshell_ls", {"path": path}))
|
||||
```
|
||||
|
||||
## Session Management
|
||||
|
||||
- MCP server spawns per command (stateless from server perspective)
|
||||
- CLI maintains state (URL, working directory, navigation history)
|
||||
- Each command re-spawns the MCP server process
|
||||
|
||||
## Daemon Mode (Optional)
|
||||
|
||||
- Spawn MCP server once, reuse connection for multiple commands
|
||||
- Reduces latency for interactive use
|
||||
- Requires explicit start/stop or `--daemon` flag
|
||||
|
||||
## Dependencies
|
||||
|
||||
Add `mcp>=0.1.0` to `install_requires`.
|
||||
|
||||
## Example Implementations
|
||||
|
||||
- `browser/agent-harness` — DOMShell MCP server for browser automation
|
||||
- See: https://github.com/HKUDS/CLI-Anything/tree/main/browser/agent-harness
|
||||
@@ -0,0 +1,284 @@
|
||||
# Preview Methodology for Harnesses
|
||||
|
||||
Use this guide when a harness can expose meaningful intermediate state through
|
||||
images, video snippets, inspection bundles, or other honest artifacts derived
|
||||
from the real software.
|
||||
|
||||
This guide complements `docs/PREVIEW_PROTOCOL.md`. The protocol defines the
|
||||
bundle/session/trajectory format. This document defines the harness-side
|
||||
methodology and the agent-facing command contract.
|
||||
|
||||
## Producer vs consumer
|
||||
|
||||
Keep preview production and preview consumption as separate roles:
|
||||
|
||||
- **Producer:** `cli-anything-<software> preview ...`
|
||||
- talks to the real backend
|
||||
- computes source fingerprints
|
||||
- chooses recipes
|
||||
- publishes bundles, sessions, and trajectories
|
||||
- **Consumer:** `cli-hub previews ...`
|
||||
- reads existing preview state
|
||||
- provides `inspect`, `html`, `watch`, and `open`
|
||||
- never renders or synthesizes preview artifacts
|
||||
|
||||
This distinction should be visible in:
|
||||
|
||||
- command help
|
||||
- README examples
|
||||
- `SKILL.md`
|
||||
- live-session viewer hints
|
||||
|
||||
The short rule is:
|
||||
|
||||
- publish with `cli-anything-<software>`
|
||||
- inspect with `cli-hub`
|
||||
|
||||
## Three-layer model: bundle, session, trajectory
|
||||
|
||||
Preview-capable harnesses should model history explicitly instead of treating
|
||||
the latest bundle directory as the permanent object.
|
||||
|
||||
### `bundle_dir`: immutable snapshot
|
||||
|
||||
A bundle directory is one concrete preview result:
|
||||
|
||||
- one static capture
|
||||
- one diff capture
|
||||
- one live-session publish step
|
||||
|
||||
It should implement `preview-bundle/v1` and contain:
|
||||
|
||||
- `manifest.json`
|
||||
- `summary.json`
|
||||
- `artifacts/`
|
||||
|
||||
Treat bundle directories as immutable once published.
|
||||
|
||||
### `session.json`: mutable live head
|
||||
|
||||
`session.json` represents the current live view for a project and recipe. It is
|
||||
the stable entry point for “what is current right now”.
|
||||
|
||||
Typical fields include:
|
||||
|
||||
- current bundle id and paths
|
||||
- session root and recipe
|
||||
- viewer commands
|
||||
- current step id
|
||||
- trajectory location
|
||||
|
||||
### `trajectory.json`: append-only permanent history
|
||||
|
||||
`trajectory.json` is the durable replay object. It should survive beyond the
|
||||
current head and allow later tooling to reconstruct how the artifact evolved.
|
||||
|
||||
At minimum, each trajectory step should capture:
|
||||
|
||||
- `step_id`
|
||||
- `step_index`
|
||||
- `command`
|
||||
- `command_started_at`
|
||||
- `command_finished_at`
|
||||
- `publish_reason`
|
||||
- `source_fingerprint`
|
||||
- `bundle_id`
|
||||
- `bundle_dir`
|
||||
- `manifest_path`
|
||||
- `summary_path`
|
||||
- optional `stage_label`
|
||||
- optional `note`
|
||||
|
||||
This is the right place to bind agent actions to preview state. Do not expect
|
||||
`_bundle_dir` alone to serve that role.
|
||||
|
||||
## Recommended CLI surface
|
||||
|
||||
### Baseline surface
|
||||
|
||||
If the software has meaningful previewable state, expose:
|
||||
|
||||
- `preview recipes`
|
||||
- `preview capture`
|
||||
- `preview latest`
|
||||
|
||||
Recommended behavior:
|
||||
|
||||
- `preview recipes`
|
||||
- list the supported preview recipes and what they produce
|
||||
- `preview capture`
|
||||
- produce a fresh or cache-reused bundle from the current source state
|
||||
- `preview latest`
|
||||
- return the newest existing bundle for the project and recipe without re-rendering
|
||||
|
||||
### Optional diff surface
|
||||
|
||||
Expose `preview diff` when the software benefits from direct A/B comparison.
|
||||
|
||||
Good fits:
|
||||
|
||||
- GPU capture tools
|
||||
- tools with before/after inspection states
|
||||
- workflows where the delta is more important than the current hero frame
|
||||
|
||||
### Optional live surface
|
||||
|
||||
Expose these when iterative live inspection is useful:
|
||||
|
||||
- `preview live start`
|
||||
- `preview live push`
|
||||
- `preview live status`
|
||||
- `preview live stop`
|
||||
|
||||
Recommended semantics:
|
||||
|
||||
- `start`
|
||||
- initialize the session root and publish the first bundle
|
||||
- `push`
|
||||
- append a new bundle into the existing live session
|
||||
- `status`
|
||||
- report current state only; do not render
|
||||
- `stop`
|
||||
- mark the session inactive but preserve all published history
|
||||
|
||||
### Optional poll-first refresh
|
||||
|
||||
Use poll-first refresh when the source of truth is file-backed and agents may
|
||||
save through separate commands between preview calls.
|
||||
|
||||
Good fits:
|
||||
|
||||
- JSON project files
|
||||
- XML timelines
|
||||
- capture files or scene files whose fingerprints are cheap to recompute
|
||||
|
||||
Poll support usually looks like:
|
||||
|
||||
- `preview live start --mode poll`
|
||||
- an internal background monitor loop
|
||||
- source fingerprint checks before rendering
|
||||
|
||||
Only add poll mode when it reduces agent friction. Do not add background loops
|
||||
that publish meaningless duplicate bundles.
|
||||
|
||||
## Agent-facing design for `preview live status --json`
|
||||
|
||||
This command exists to make the live loop cheap for agents.
|
||||
|
||||
It should answer:
|
||||
|
||||
- does a live session exist?
|
||||
- is it active?
|
||||
- what is the current bundle?
|
||||
- what was the latest publish reason?
|
||||
- what is the latest command-to-preview mapping?
|
||||
|
||||
Recommended fields:
|
||||
|
||||
- `status`
|
||||
- `active`
|
||||
- `_session_dir`
|
||||
- `_session_path`
|
||||
- `current_bundle_id`
|
||||
- `current_bundle_dir`
|
||||
- `current_manifest_path`
|
||||
- `current_summary_path`
|
||||
- `_trajectory_path`
|
||||
- `current_step_id`
|
||||
- `latest_command`
|
||||
- `latest_publish_reason`
|
||||
- `trajectory_summary`
|
||||
|
||||
`trajectory_summary` should be compact and cheap to parse. Include:
|
||||
|
||||
- `step_count`
|
||||
- `current_step_id`
|
||||
- `latest_command`
|
||||
- `latest_publish_reason`
|
||||
- `latest_bundle_id`
|
||||
- `recent_steps`
|
||||
|
||||
This lets an agent decide whether the session is progressing without opening the
|
||||
full trajectory file on every loop.
|
||||
|
||||
## README and SKILL guidance
|
||||
|
||||
Preview-capable harnesses should explain preview in both `README.md` and
|
||||
`SKILL.md`.
|
||||
|
||||
### README.md should cover
|
||||
|
||||
- what preview modes exist
|
||||
- what each recipe emits
|
||||
- how to publish a bundle
|
||||
- how to inspect/watch/open it with `cli-hub previews ...`
|
||||
- how live sessions behave
|
||||
- any truthfulness caveats, such as injected cameras or helper rigs
|
||||
|
||||
### SKILL.md should cover
|
||||
|
||||
- the producer command surface under `preview`
|
||||
- whether `diff`, `live`, and poll mode are available
|
||||
- the fact that `cli-hub previews ...` is the read-only consumer
|
||||
- agent guidance for `--json`
|
||||
- what artifact roles to expect, such as `hero`, `gallery`, `clip`, or diff outputs
|
||||
|
||||
Every preview example should show both sides:
|
||||
|
||||
```bash
|
||||
cli-anything-<software> --project demo.ext preview capture --recipe quick --json
|
||||
cli-hub previews inspect /path/to/bundle
|
||||
```
|
||||
|
||||
## When to add diff, live, or poll
|
||||
|
||||
Use this decision table:
|
||||
|
||||
| Capability | Add it when... | Avoid it when... |
|
||||
|------------|----------------|------------------|
|
||||
| `preview diff` | the comparison itself is the product | the current-state bundle already answers the question |
|
||||
| `preview live` | iterative agent work benefits from a stable current head | the tool only produces occasional one-shot exports |
|
||||
| poll-first refresh | project fingerprints change outside preview commands | the source is expensive to fingerprint or updates are rare |
|
||||
|
||||
Do not add complexity just to match another harness. Add the capability only
|
||||
when it matches the software’s actual iteration loop.
|
||||
|
||||
## Truthfulness rules
|
||||
|
||||
Previews must be honest enough for agent decisions.
|
||||
|
||||
Preferred sources, in order:
|
||||
|
||||
1. native render/export from the real backend
|
||||
2. native inspection or replay outputs from the real tool
|
||||
3. real-project offscreen capture helpers
|
||||
|
||||
Avoid:
|
||||
|
||||
- fake renders synthesized outside the tool
|
||||
- screen recordings of the GUI as the primary preview artifact
|
||||
- approximations that silently diverge from what the software would really produce
|
||||
|
||||
If the harness needs temporary helper state, surface that honestly:
|
||||
|
||||
- mark the bundle `partial` or equivalent
|
||||
- note injected preview cameras, lights, or helpers in summary/context output
|
||||
- keep the project fingerprint separate from the injected preview rig when possible
|
||||
|
||||
## Implementation checklist
|
||||
|
||||
- Decide whether preview is meaningful for this software.
|
||||
- Define one or more recipes with clear outputs.
|
||||
- Publish `preview-bundle/v1` bundles.
|
||||
- Keep bundle, session, and trajectory distinct.
|
||||
- Ensure all preview commands support `--json`.
|
||||
- Make `preview latest` read-only.
|
||||
- Make `preview live status --json` cheap for agents.
|
||||
- Document producer vs consumer commands in README and SKILL.
|
||||
- Verify outputs come from the real backend.
|
||||
|
||||
## Related references
|
||||
|
||||
- [`../HARNESS.md`](../HARNESS.md)
|
||||
- [`skill-generation.md`](skill-generation.md)
|
||||
- [`../../docs/PREVIEW_PROTOCOL.md`](../../docs/PREVIEW_PROTOCOL.md)
|
||||
@@ -0,0 +1,117 @@
|
||||
# PyPI Publishing and Installation (Phase 7)
|
||||
|
||||
After building and testing the CLI, make it installable and discoverable.
|
||||
|
||||
All cli-anything CLIs use **PEP 420 namespace packages** under the shared
|
||||
`cli_anything` namespace. This allows multiple CLI packages to be installed
|
||||
side-by-side in the same Python environment without conflicts.
|
||||
|
||||
## 1. Package Structure
|
||||
|
||||
```
|
||||
agent-harness/
|
||||
├── setup.py
|
||||
└── cli_anything/ # NO __init__.py here (namespace package)
|
||||
└── <software>/ # e.g., gimp, blender, audacity
|
||||
├── __init__.py # HAS __init__.py (regular sub-package)
|
||||
├── <software>_cli.py
|
||||
├── core/
|
||||
├── utils/
|
||||
└── tests/
|
||||
```
|
||||
|
||||
The key rule: `cli_anything/` has **no** `__init__.py`. Each sub-package
|
||||
(`gimp/`, `blender/`, etc.) **does** have `__init__.py`. This is what
|
||||
enables multiple packages to contribute to the same namespace.
|
||||
|
||||
## 2. setup.py Template
|
||||
|
||||
Create `setup.py` in the `agent-harness/` directory:
|
||||
|
||||
```python
|
||||
from setuptools import setup, find_namespace_packages
|
||||
|
||||
setup(
|
||||
name="cli-anything-<software>",
|
||||
version="1.0.0",
|
||||
packages=find_namespace_packages(include=["cli_anything.*"]),
|
||||
install_requires=[
|
||||
"click>=8.0.0",
|
||||
"prompt-toolkit>=3.0.0",
|
||||
# Add Python library dependencies here
|
||||
],
|
||||
entry_points={
|
||||
"console_scripts": [
|
||||
"cli-anything-<software>=cli_anything.<software>.<software>_cli:main",
|
||||
],
|
||||
},
|
||||
python_requires=">=3.10",
|
||||
)
|
||||
```
|
||||
|
||||
**Important details:**
|
||||
- Use `find_namespace_packages`, NOT `find_packages`
|
||||
- Use `include=["cli_anything.*"]` to scope discovery
|
||||
- Entry point format: `cli_anything.<software>.<software>_cli:main`
|
||||
- The **system package** (LibreOffice, Blender, etc.) is a **hard dependency**
|
||||
that cannot be expressed in `install_requires`. Document it in README.md and
|
||||
have the backend module raise a clear error with install instructions:
|
||||
```python
|
||||
# In utils/<software>_backend.py
|
||||
def find_<software>():
|
||||
path = shutil.which("<software>")
|
||||
if path:
|
||||
return path
|
||||
raise RuntimeError(
|
||||
"<Software> is not installed. Install it with:\n"
|
||||
" apt install <software> # Debian/Ubuntu\n"
|
||||
" brew install <software> # macOS"
|
||||
)
|
||||
```
|
||||
|
||||
## 3. Import Convention
|
||||
|
||||
All imports use the `cli_anything.<software>` prefix:
|
||||
|
||||
```python
|
||||
from cli_anything.gimp.core.project import create_project
|
||||
from cli_anything.gimp.core.session import Session
|
||||
from cli_anything.blender.core.scene import create_scene
|
||||
```
|
||||
|
||||
## 4. Verification Steps
|
||||
|
||||
**Test local installation:**
|
||||
```bash
|
||||
cd /root/cli-anything/<software>/agent-harness
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
**Verify PATH installation:**
|
||||
```bash
|
||||
which cli-anything-<software>
|
||||
cli-anything-<software> --help
|
||||
```
|
||||
|
||||
**Run tests against the installed command:**
|
||||
```bash
|
||||
cd /root/cli-anything/<software>/agent-harness
|
||||
CLI_ANYTHING_FORCE_INSTALLED=1 python3 -m pytest cli_anything/<software>/tests/ -v -s
|
||||
```
|
||||
The output must show `[_resolve_cli] Using installed command: /path/to/cli-anything-<software>`
|
||||
confirming subprocess tests ran against the real installed binary, not a module fallback.
|
||||
|
||||
**Verify namespace works across packages** (when multiple CLIs installed):
|
||||
```python
|
||||
import cli_anything.gimp
|
||||
import cli_anything.blender
|
||||
# Both resolve to their respective source directories
|
||||
```
|
||||
|
||||
## Why Namespace Packages
|
||||
|
||||
- Multiple CLIs coexist in the same Python environment without conflicts
|
||||
- Clean, organized imports under a single `cli_anything` namespace
|
||||
- Each CLI is independently installable/uninstallable via pip
|
||||
- Agents can discover all installed CLIs via `cli_anything.*`
|
||||
- Standard Python packaging — no hacks or workarounds
|
||||
@@ -0,0 +1,39 @@
|
||||
# Session File Locking
|
||||
|
||||
When saving session JSON, use exclusive file locking to prevent concurrent writes from corrupting data.
|
||||
|
||||
## The Problem
|
||||
|
||||
Never use bare `open("w") + json.dump()` — `open("w")` truncates the file before any lock can be acquired.
|
||||
|
||||
## The Solution: `_locked_save_json`
|
||||
|
||||
Open with `"r+"`, lock, then truncate inside the lock:
|
||||
|
||||
```python
|
||||
def _locked_save_json(path, data, **dump_kwargs) -> None:
|
||||
"""Atomically write JSON with exclusive file locking."""
|
||||
try:
|
||||
f = open(path, "r+") # no truncation on open
|
||||
except FileNotFoundError:
|
||||
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
||||
f = open(path, "w") # first save — file doesn't exist yet
|
||||
with f:
|
||||
_locked = False
|
||||
try:
|
||||
import fcntl
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||||
_locked = True
|
||||
except (ImportError, OSError):
|
||||
pass # Windows / unsupported FS — proceed unlocked
|
||||
try:
|
||||
f.seek(0)
|
||||
f.truncate() # truncate INSIDE the lock
|
||||
json.dump(data, f, **dump_kwargs)
|
||||
f.flush()
|
||||
finally:
|
||||
if _locked:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
```
|
||||
|
||||
Copy this pattern into `core/session.py` for all session saves.
|
||||
@@ -0,0 +1,135 @@
|
||||
# SKILL.md Generation (Phase 6.5)
|
||||
|
||||
Generate a SKILL.md file that makes the CLI discoverable and usable by AI agents
|
||||
through the skill-creator methodology. This file serves as a self-contained skill
|
||||
definition that can be loaded by Claude Code or other AI assistants.
|
||||
|
||||
## Purpose
|
||||
|
||||
SKILL.md files follow a standard format that enables AI agents to:
|
||||
- Discover the CLI's capabilities
|
||||
- Understand command structure and usage
|
||||
- Generate correct command invocations
|
||||
- Handle output programmatically
|
||||
|
||||
## SKILL.md Structure
|
||||
|
||||
### 1. YAML Frontmatter — Triggering metadata for skill discovery:
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: "cli-anything-<software>"
|
||||
description: "Brief description of what the CLI does"
|
||||
---
|
||||
```
|
||||
|
||||
### 2. Markdown Body — Usage instructions including:
|
||||
|
||||
- Installation prerequisites
|
||||
- Basic command syntax
|
||||
- Command groups and their functions
|
||||
- Usage examples
|
||||
- Agent-specific guidance (JSON output, error handling)
|
||||
|
||||
## Generation Process
|
||||
|
||||
### 1. Extract CLI metadata using `skill_generator.py`:
|
||||
|
||||
```python
|
||||
from skill_generator import generate_skill_file
|
||||
|
||||
skill_path = generate_skill_file(
|
||||
harness_path="/path/to/agent-harness"
|
||||
)
|
||||
# Default output: skills/cli-anything-<software>/SKILL.md
|
||||
```
|
||||
|
||||
### 2. The generator automatically extracts:
|
||||
|
||||
- Software name and version from setup.py
|
||||
- Command groups from the CLI file (Click decorators)
|
||||
- Documentation from README.md
|
||||
- System package requirements
|
||||
|
||||
### 3. Customize the template (optional):
|
||||
|
||||
- Default template: `templates/SKILL.md.template`
|
||||
- Uses Jinja2 placeholders for dynamic content
|
||||
- Can be extended for software-specific sections
|
||||
|
||||
## Output Location
|
||||
|
||||
SKILL.md is generated canonically at the repo root, with a packaged compatibility
|
||||
copy for installed harnesses:
|
||||
|
||||
```
|
||||
skills/
|
||||
└── cli-anything-<software>/
|
||||
└── SKILL.md
|
||||
|
||||
<software>/
|
||||
└── agent-harness/
|
||||
└── cli_anything/
|
||||
└── <software>/
|
||||
└── skills/
|
||||
└── SKILL.md
|
||||
```
|
||||
|
||||
## Manual Generation
|
||||
|
||||
```bash
|
||||
cd cli-anything-plugin
|
||||
python skill_generator.py /path/to/software/agent-harness
|
||||
```
|
||||
|
||||
## Integration with CLI Build
|
||||
|
||||
The SKILL.md generation should be run after Phase 6 (Test Documentation) completes
|
||||
successfully, ensuring the CLI is fully documented and tested before creating the
|
||||
skill definition.
|
||||
|
||||
## Key Principles
|
||||
|
||||
- SKILL.md must be self-contained (no external dependencies for understanding)
|
||||
- Include agent-specific guidance for programmatic usage
|
||||
- Document `--json` flag usage for machine-readable output
|
||||
- List all command groups with brief descriptions
|
||||
- Provide realistic examples that demonstrate common workflows
|
||||
|
||||
## Preview-Capable Harnesses
|
||||
|
||||
If the harness supports previews, the generated or edited `SKILL.md` should
|
||||
include a dedicated preview section that covers:
|
||||
|
||||
- the producer command surface: `cli-anything-<software> preview ...`
|
||||
- the consumer command surface: `cli-hub previews ...`
|
||||
- whether `preview diff`, `preview live ...`, or poll mode exist
|
||||
- how agents should use `--json` results and artifact paths
|
||||
- at least one publish example and one inspect/watch example
|
||||
|
||||
For the full preview methodology, see
|
||||
[`preview-methodology.md`](preview-methodology.md).
|
||||
|
||||
## Skill Path in CLI Banner
|
||||
|
||||
ReplSkin prefers the repo-root canonical skill file and falls back to the
|
||||
packaged copy, displaying whichever absolute path is available in the startup
|
||||
banner. AI agents can read the file at the displayed path:
|
||||
|
||||
```python
|
||||
# In the REPL initialization (e.g., shotcut_cli.py)
|
||||
from cli_anything.<software>.utils.repl_skin import ReplSkin
|
||||
|
||||
skin = ReplSkin("<software>", version="1.0.0")
|
||||
skin.print_banner() # Displays repo-root skills/cli-anything-<software>/SKILL.md when available
|
||||
```
|
||||
|
||||
## Package Data
|
||||
|
||||
Ensure `setup.py` includes the skill file as package data so it is installed with pip:
|
||||
|
||||
```python
|
||||
package_data={
|
||||
"cli_anything.<software>": ["skills/*.md"],
|
||||
},
|
||||
```
|
||||
@@ -0,0 +1,22 @@
|
||||
# Timecode Precision
|
||||
|
||||
Non-integer frame rates (29.97fps = 30000/1001) cause cumulative rounding errors. Follow these rules to avoid drift.
|
||||
|
||||
## Use `round()`, Not `int()`
|
||||
|
||||
For float-to-frame conversion:
|
||||
- `int(9000 * 29.97)` — **wrong**, truncates and loses frames
|
||||
- `round(9000 * 29.97)` — **correct**, gets the right answer
|
||||
|
||||
## Use Integer Arithmetic for Timecode Display
|
||||
|
||||
Convert frames → total milliseconds via:
|
||||
```python
|
||||
total_ms = round(frames * fps_den * 1000 / fps_num)
|
||||
```
|
||||
|
||||
Then decompose with integer division. Avoid intermediate floats that drift over long durations.
|
||||
|
||||
## Accept ±1 Frame Tolerance
|
||||
|
||||
In roundtrip tests at non-integer FPS, exact equality is mathematically impossible. Accept ±1 frame tolerance.
|
||||
@@ -0,0 +1,468 @@
|
||||
"""Shared helpers for CLI-Anything preview bundles."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, Optional
|
||||
|
||||
PROTOCOL_VERSION = "preview-bundle/v1"
|
||||
TRAJECTORY_PROTOCOL_VERSION = "preview-trajectory/v1"
|
||||
|
||||
|
||||
def _slug(value: str) -> str:
|
||||
text = (value or "preview").strip().lower()
|
||||
text = re.sub(r"[^a-z0-9]+", "-", text)
|
||||
text = re.sub(r"-{2,}", "-", text).strip("-")
|
||||
return text or "preview"
|
||||
|
||||
|
||||
def _json_dumps(data: Any) -> str:
|
||||
return json.dumps(
|
||||
data,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
)
|
||||
|
||||
|
||||
def hash_data(data: Any) -> str:
|
||||
return hashlib.sha256(_json_dumps(data).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def fingerprint_data(data: Any) -> str:
|
||||
return f"sha256:{hash_data(data)}"
|
||||
|
||||
|
||||
def fingerprint_file(path: str) -> str:
|
||||
resolved = os.path.abspath(path)
|
||||
stat = os.stat(resolved)
|
||||
return fingerprint_data(
|
||||
{
|
||||
"path": resolved,
|
||||
"size": stat.st_size,
|
||||
"mtime_ns": stat.st_mtime_ns,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def bundle_root(
|
||||
software: str,
|
||||
recipe: str,
|
||||
project_path: Optional[str] = None,
|
||||
root_dir: Optional[str] = None,
|
||||
) -> Path:
|
||||
if root_dir:
|
||||
base = Path(root_dir).expanduser().resolve()
|
||||
elif project_path:
|
||||
base = Path(project_path).expanduser().resolve().parent / ".cli-anything" / "previews"
|
||||
else:
|
||||
base = Path.home() / ".cli-anything" / "previews"
|
||||
return base / _slug(software) / _slug(recipe)
|
||||
|
||||
|
||||
def build_cache_key(
|
||||
software: str,
|
||||
recipe: str,
|
||||
bundle_kind: str,
|
||||
source_fingerprint: str,
|
||||
options: Optional[Dict[str, Any]] = None,
|
||||
harness_version: Optional[str] = None,
|
||||
protocol_version: str = PROTOCOL_VERSION,
|
||||
) -> str:
|
||||
return fingerprint_data(
|
||||
{
|
||||
"protocol_version": protocol_version,
|
||||
"software": software,
|
||||
"recipe": recipe,
|
||||
"bundle_kind": bundle_kind,
|
||||
"source_fingerprint": source_fingerprint,
|
||||
"options": options or {},
|
||||
"harness_version": harness_version or "",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _iter_manifests(search_root: Path) -> Iterable[Path]:
|
||||
if not search_root.exists():
|
||||
return []
|
||||
return sorted(search_root.rglob("manifest.json"), reverse=True)
|
||||
|
||||
|
||||
def _load_json(path: Path) -> Dict[str, Any]:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
def find_cached_manifest(
|
||||
software: str,
|
||||
recipe: str,
|
||||
bundle_kind: str,
|
||||
cache_key: str,
|
||||
project_path: Optional[str] = None,
|
||||
root_dir: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
root = bundle_root(software, recipe, project_path=project_path, root_dir=root_dir)
|
||||
for manifest_path in _iter_manifests(root):
|
||||
try:
|
||||
manifest = _load_json(manifest_path)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
if (
|
||||
manifest.get("protocol_version") == PROTOCOL_VERSION
|
||||
and manifest.get("software") == software
|
||||
and manifest.get("recipe") == recipe
|
||||
and manifest.get("bundle_kind") == bundle_kind
|
||||
and manifest.get("status") in {"ok", "partial"}
|
||||
and manifest.get("cache_key") == cache_key
|
||||
):
|
||||
manifest["_manifest_path"] = str(manifest_path.resolve())
|
||||
manifest["_bundle_dir"] = str(manifest_path.parent.resolve())
|
||||
manifest["_summary_path"] = str(
|
||||
(manifest_path.parent / manifest.get("summary_path", "summary.json")).resolve()
|
||||
)
|
||||
return manifest
|
||||
return None
|
||||
|
||||
|
||||
def find_latest_manifest(
|
||||
software: str,
|
||||
recipe: Optional[str] = None,
|
||||
bundle_kind: Optional[str] = None,
|
||||
project_path: Optional[str] = None,
|
||||
root_dir: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if root_dir:
|
||||
search_root = Path(root_dir).expanduser().resolve() / _slug(software)
|
||||
elif project_path:
|
||||
search_root = Path(project_path).expanduser().resolve().parent / ".cli-anything" / "previews" / _slug(software)
|
||||
else:
|
||||
search_root = Path.home() / ".cli-anything" / "previews" / _slug(software)
|
||||
if recipe:
|
||||
search_root = search_root / _slug(recipe)
|
||||
for manifest_path in _iter_manifests(search_root):
|
||||
try:
|
||||
manifest = _load_json(manifest_path)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
if manifest.get("software") != software:
|
||||
continue
|
||||
if recipe and manifest.get("recipe") != recipe:
|
||||
continue
|
||||
if bundle_kind and manifest.get("bundle_kind") != bundle_kind:
|
||||
continue
|
||||
if manifest.get("status") not in {"ok", "partial"}:
|
||||
continue
|
||||
manifest["_manifest_path"] = str(manifest_path.resolve())
|
||||
manifest["_bundle_dir"] = str(manifest_path.parent.resolve())
|
||||
manifest["_summary_path"] = str(
|
||||
(manifest_path.parent / manifest.get("summary_path", "summary.json")).resolve()
|
||||
)
|
||||
return manifest
|
||||
return None
|
||||
|
||||
|
||||
def prepare_bundle(
|
||||
software: str,
|
||||
recipe: str,
|
||||
bundle_kind: str,
|
||||
source_fingerprint: str,
|
||||
options: Optional[Dict[str, Any]] = None,
|
||||
harness_version: Optional[str] = None,
|
||||
project_path: Optional[str] = None,
|
||||
root_dir: Optional[str] = None,
|
||||
force: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
cache_key = build_cache_key(
|
||||
software=software,
|
||||
recipe=recipe,
|
||||
bundle_kind=bundle_kind,
|
||||
source_fingerprint=source_fingerprint,
|
||||
options=options or {},
|
||||
harness_version=harness_version,
|
||||
)
|
||||
if not force:
|
||||
cached = find_cached_manifest(
|
||||
software=software,
|
||||
recipe=recipe,
|
||||
bundle_kind=bundle_kind,
|
||||
cache_key=cache_key,
|
||||
project_path=project_path,
|
||||
root_dir=root_dir,
|
||||
)
|
||||
if cached:
|
||||
return {
|
||||
"cached": True,
|
||||
"cache_key": cache_key,
|
||||
"bundle_id": cached.get("bundle_id"),
|
||||
"bundle_dir": cached["_bundle_dir"],
|
||||
"manifest_path": cached["_manifest_path"],
|
||||
"summary_path": os.path.join(cached["_bundle_dir"], cached.get("summary_path", "summary.json")),
|
||||
"manifest": cached,
|
||||
}
|
||||
|
||||
now = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
bundle_id = f"{now}_{cache_key.split(':', 1)[-1][:8]}_{_slug(recipe)}"
|
||||
out_dir = bundle_root(software, recipe, project_path=project_path, root_dir=root_dir) / bundle_id
|
||||
artifacts_dir = out_dir / "artifacts"
|
||||
artifacts_dir.mkdir(parents=True, exist_ok=False)
|
||||
return {
|
||||
"cached": False,
|
||||
"cache_key": cache_key,
|
||||
"bundle_id": bundle_id,
|
||||
"bundle_dir": str(out_dir.resolve()),
|
||||
"artifacts_dir": str(artifacts_dir.resolve()),
|
||||
"manifest_path": str((out_dir / "manifest.json").resolve()),
|
||||
"summary_path": str((out_dir / "summary.json").resolve()),
|
||||
}
|
||||
|
||||
|
||||
def artifact_record(
|
||||
bundle_dir: str,
|
||||
path: str,
|
||||
artifact_id: str,
|
||||
role: str,
|
||||
kind: str,
|
||||
label: str,
|
||||
media_type: Optional[str] = None,
|
||||
**extra: Any,
|
||||
) -> Dict[str, Any]:
|
||||
bundle_path = Path(bundle_dir).resolve()
|
||||
file_path = Path(path).resolve()
|
||||
rel_path = file_path.relative_to(bundle_path).as_posix()
|
||||
record: Dict[str, Any] = {
|
||||
"artifact_id": artifact_id,
|
||||
"role": role,
|
||||
"kind": kind,
|
||||
"label": label,
|
||||
"media_type": media_type or mimetypes.guess_type(str(file_path))[0] or "application/octet-stream",
|
||||
"path": rel_path,
|
||||
}
|
||||
if file_path.exists():
|
||||
record["bytes"] = file_path.stat().st_size
|
||||
record.update({k: v for k, v in extra.items() if v is not None})
|
||||
return record
|
||||
|
||||
|
||||
def write_json(path: str, data: Any) -> str:
|
||||
output_path = Path(path).resolve()
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_path, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh, indent=2, ensure_ascii=False, default=str)
|
||||
fh.write("\n")
|
||||
return str(output_path)
|
||||
|
||||
|
||||
def finalize_bundle(
|
||||
bundle_dir: str,
|
||||
bundle_id: str,
|
||||
bundle_kind: str,
|
||||
software: str,
|
||||
recipe: str,
|
||||
source: Dict[str, Any],
|
||||
artifacts: list[Dict[str, Any]],
|
||||
summary: Dict[str, Any],
|
||||
cache_key: str,
|
||||
generator: Dict[str, Any],
|
||||
status: str = "ok",
|
||||
warnings: Optional[list[str]] = None,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
metrics: Optional[Dict[str, Any]] = None,
|
||||
labels: Optional[list[str]] = None,
|
||||
source_bundles: Optional[list[Dict[str, Any]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
bundle_path = Path(bundle_dir).resolve()
|
||||
summary_rel = "summary.json"
|
||||
summary_path = write_json(str(bundle_path / summary_rel), summary)
|
||||
manifest = {
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"bundle_id": bundle_id,
|
||||
"bundle_kind": bundle_kind,
|
||||
"software": software,
|
||||
"recipe": recipe,
|
||||
"status": status,
|
||||
"created_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||
"cache_key": cache_key,
|
||||
"generator": generator,
|
||||
"source": source,
|
||||
"summary_path": summary_rel,
|
||||
"artifacts": artifacts,
|
||||
}
|
||||
if warnings:
|
||||
manifest["warnings"] = warnings
|
||||
if context:
|
||||
manifest["context"] = context
|
||||
if metrics:
|
||||
manifest["metrics"] = metrics
|
||||
if labels:
|
||||
manifest["labels"] = labels
|
||||
if source_bundles:
|
||||
manifest["source_bundles"] = source_bundles
|
||||
manifest_path = write_json(str(bundle_path / "manifest.json"), manifest)
|
||||
manifest["_manifest_path"] = manifest_path
|
||||
manifest["_bundle_dir"] = str(bundle_path)
|
||||
manifest["_summary_path"] = summary_path
|
||||
return manifest
|
||||
|
||||
|
||||
def _clean_none_fields(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {key: value for key, value in data.items() if value is not None}
|
||||
|
||||
|
||||
def live_trajectory_path(session_dir: str | Path) -> Path:
|
||||
return Path(session_dir).expanduser().resolve() / "trajectory.json"
|
||||
|
||||
|
||||
def load_live_trajectory(session_dir: str | Path) -> Dict[str, Any]:
|
||||
trajectory_path = live_trajectory_path(session_dir)
|
||||
if not trajectory_path.is_file():
|
||||
return {}
|
||||
return _load_json(trajectory_path)
|
||||
|
||||
|
||||
def summarize_trajectory(trajectory: Dict[str, Any], *, recent_steps: int = 3) -> Dict[str, Any]:
|
||||
steps = list(trajectory.get("steps") or [])
|
||||
latest = steps[-1] if steps else {}
|
||||
recent = steps[-max(1, int(recent_steps)):] if steps else []
|
||||
return _clean_none_fields(
|
||||
{
|
||||
"protocol_version": trajectory.get("protocol_version"),
|
||||
"software": trajectory.get("software"),
|
||||
"recipe": trajectory.get("recipe"),
|
||||
"step_count": trajectory.get("step_count", len(steps)),
|
||||
"current_step_id": trajectory.get("current_step_id"),
|
||||
"latest_command": latest.get("command"),
|
||||
"latest_publish_reason": latest.get("publish_reason"),
|
||||
"latest_bundle_id": latest.get("bundle_id"),
|
||||
"recent_steps": [
|
||||
_clean_none_fields(
|
||||
{
|
||||
"step_id": item.get("step_id"),
|
||||
"step_index": item.get("step_index"),
|
||||
"bundle_id": item.get("bundle_id"),
|
||||
"publish_reason": item.get("publish_reason"),
|
||||
"command": item.get("command"),
|
||||
"command_finished_at": item.get("command_finished_at"),
|
||||
"status": item.get("status"),
|
||||
"cached": item.get("cached"),
|
||||
}
|
||||
)
|
||||
for item in recent
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def build_live_history_item(
|
||||
bundle_manifest: Dict[str, Any],
|
||||
*,
|
||||
step_id: Optional[str] = None,
|
||||
step_index: Optional[int] = None,
|
||||
publish_reason: Optional[str] = None,
|
||||
command: Optional[str] = None,
|
||||
command_started_at: Optional[str] = None,
|
||||
command_finished_at: Optional[str] = None,
|
||||
source_fingerprint: Optional[str] = None,
|
||||
stage_label: Optional[str] = None,
|
||||
note: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
source = bundle_manifest.get("source") or {}
|
||||
resolved_command = command or (bundle_manifest.get("generator") or {}).get("command")
|
||||
resolved_fingerprint = (
|
||||
source_fingerprint
|
||||
or source.get("project_fingerprint")
|
||||
or source.get("capture_fingerprint")
|
||||
)
|
||||
created_at = bundle_manifest.get("created_at")
|
||||
return _clean_none_fields(
|
||||
{
|
||||
"step_id": step_id,
|
||||
"step_index": step_index,
|
||||
"bundle_id": bundle_manifest.get("bundle_id"),
|
||||
"bundle_dir": bundle_manifest.get("_bundle_dir"),
|
||||
"manifest_path": bundle_manifest.get("_manifest_path"),
|
||||
"summary_path": bundle_manifest.get("_summary_path"),
|
||||
"created_at": created_at,
|
||||
"status": bundle_manifest.get("status"),
|
||||
"cached": bool(bundle_manifest.get("cached")),
|
||||
"publish_reason": publish_reason,
|
||||
"command": resolved_command,
|
||||
"command_started_at": command_started_at or created_at,
|
||||
"command_finished_at": command_finished_at or created_at,
|
||||
"source_fingerprint": resolved_fingerprint,
|
||||
"stage_label": stage_label,
|
||||
"note": note,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def append_live_trajectory(
|
||||
session_dir: str | Path,
|
||||
*,
|
||||
software: str,
|
||||
recipe: str,
|
||||
bundle_manifest: Dict[str, Any],
|
||||
publish_reason: str,
|
||||
project_path: Optional[str] = None,
|
||||
project_name: Optional[str] = None,
|
||||
session_name: Optional[str] = None,
|
||||
command: Optional[str] = None,
|
||||
command_started_at: Optional[str] = None,
|
||||
command_finished_at: Optional[str] = None,
|
||||
source_fingerprint: Optional[str] = None,
|
||||
stage_label: Optional[str] = None,
|
||||
note: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
session_path = Path(session_dir).expanduser().resolve()
|
||||
existing = load_live_trajectory(session_path)
|
||||
steps = list(existing.get("steps") or [])
|
||||
finished_at = (
|
||||
command_finished_at
|
||||
or bundle_manifest.get("created_at")
|
||||
or datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
)
|
||||
started_at = command_started_at or finished_at
|
||||
step_index = len(steps) + 1
|
||||
step_id = f"step-{step_index:04d}"
|
||||
step = build_live_history_item(
|
||||
bundle_manifest,
|
||||
step_id=step_id,
|
||||
step_index=step_index,
|
||||
publish_reason=publish_reason,
|
||||
command=command,
|
||||
command_started_at=started_at,
|
||||
command_finished_at=finished_at,
|
||||
source_fingerprint=source_fingerprint,
|
||||
stage_label=stage_label,
|
||||
note=note,
|
||||
)
|
||||
steps.append(step)
|
||||
|
||||
trajectory: Dict[str, Any] = dict(existing)
|
||||
trajectory.update(
|
||||
_clean_none_fields(
|
||||
{
|
||||
"protocol_version": TRAJECTORY_PROTOCOL_VERSION,
|
||||
"software": software,
|
||||
"recipe": recipe,
|
||||
"session_name": session_name or session_path.name,
|
||||
"project_path": project_path,
|
||||
"project_name": project_name,
|
||||
"created_at": existing.get("created_at", finished_at),
|
||||
"updated_at": finished_at,
|
||||
"step_count": len(steps),
|
||||
"current_step_id": step_id,
|
||||
}
|
||||
)
|
||||
)
|
||||
trajectory["steps"] = steps
|
||||
trajectory_path = write_json(str(live_trajectory_path(session_path)), trajectory)
|
||||
trajectory["_trajectory_path"] = trajectory_path
|
||||
trajectory["latest_step"] = step
|
||||
return trajectory
|
||||
@@ -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
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env bash
|
||||
# cli-anything plugin setup script
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Windows bash environment check (helps avoid cryptic cygpath errors later)
|
||||
is_windows_bash() {
|
||||
case "$(uname -s 2>/dev/null)" in
|
||||
CYGWIN*|MINGW*|MSYS*) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
if is_windows_bash && ! command -v cygpath >/dev/null 2>&1; then
|
||||
echo -e "${RED}✗${NC} Windows bash environment detected but 'cygpath' was not found."
|
||||
echo -e "${YELLOW} Please install Git for Windows (Git Bash) or use WSL, then rerun this script.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Plugin info
|
||||
PLUGIN_NAME="cli-anything"
|
||||
PLUGIN_VERSION="1.0.0"
|
||||
|
||||
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${BLUE} cli-anything Plugin v${PLUGIN_VERSION}${NC}"
|
||||
echo -e "${BLUE} Build powerful CLI interfaces for any GUI application${NC}"
|
||||
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo ""
|
||||
|
||||
# Check if HARNESS.md exists
|
||||
HARNESS_PATH="/root/cli-anything/HARNESS.md"
|
||||
if [ ! -f "$HARNESS_PATH" ]; then
|
||||
echo -e "${YELLOW}⚠️ HARNESS.md not found at $HARNESS_PATH${NC}"
|
||||
echo -e "${YELLOW} The cli-anything methodology requires HARNESS.md${NC}"
|
||||
echo -e "${YELLOW} You can create it or specify a custom path with --harness-path${NC}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Check Python version
|
||||
if command -v python3 &> /dev/null; then
|
||||
PYTHON_VERSION=$(python3 --version 2>&1 | awk '{print $2}')
|
||||
echo -e "${GREEN}✓${NC} Python 3 detected: ${PYTHON_VERSION}"
|
||||
else
|
||||
echo -e "${RED}✗${NC} Python 3 not found. Please install Python 3.10+"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for required Python packages
|
||||
echo ""
|
||||
echo "Checking Python dependencies..."
|
||||
|
||||
check_package() {
|
||||
local package=$1
|
||||
if python3 -c "import $package" 2>/dev/null; then
|
||||
echo -e "${GREEN}✓${NC} $package installed"
|
||||
return 0
|
||||
else
|
||||
echo -e "${YELLOW}⚠${NC} $package not installed"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
MISSING_PACKAGES=()
|
||||
|
||||
check_package "click" || MISSING_PACKAGES+=("click")
|
||||
check_package "pytest" || MISSING_PACKAGES+=("pytest")
|
||||
|
||||
if [ ${#MISSING_PACKAGES[@]} -gt 0 ]; then
|
||||
echo ""
|
||||
echo -e "${YELLOW}Missing packages: ${MISSING_PACKAGES[*]}${NC}"
|
||||
echo -e "${YELLOW}Install with: pip install ${MISSING_PACKAGES[*]}${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${GREEN} Plugin installed successfully!${NC}"
|
||||
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo ""
|
||||
echo "Available commands:"
|
||||
echo ""
|
||||
echo -e " ${BLUE}/cli-anything${NC} <path-or-repo> - Build complete CLI harness"
|
||||
echo -e " ${BLUE}/cli-anything:refine${NC} <path> [focus] - Refine existing harness"
|
||||
echo -e " ${BLUE}/cli-anything:test${NC} <path-or-repo> - Run tests and update TEST.md"
|
||||
echo -e " ${BLUE}/cli-anything:validate${NC} <path-or-repo> - Validate against standards"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo ""
|
||||
echo -e " ${BLUE}/cli-anything${NC} /home/user/gimp"
|
||||
echo -e " ${BLUE}/cli-anything:refine${NC} /home/user/blender \"particle systems\""
|
||||
echo -e " ${BLUE}/cli-anything:test${NC} /home/user/inkscape"
|
||||
echo -e " ${BLUE}/cli-anything:validate${NC} /home/user/audacity"
|
||||
echo ""
|
||||
echo "Documentation:"
|
||||
echo ""
|
||||
echo " HARNESS.md: /root/cli-anything/HARNESS.md"
|
||||
echo " Plugin README: Use '/help cli-anything' for more info"
|
||||
echo ""
|
||||
echo -e "${GREEN}Ready to build CLI harnesses! 🚀${NC}"
|
||||
echo ""
|
||||
@@ -0,0 +1,586 @@
|
||||
"""
|
||||
SKILL.md Generator for CLI-Anything
|
||||
|
||||
This module extracts metadata from CLI-Anything harnesses and generates
|
||||
SKILL.md files following the skill-creator methodology.
|
||||
|
||||
The generated SKILL.md files contain:
|
||||
- YAML frontmatter with name and description (triggering metadata)
|
||||
- Markdown body with usage instructions
|
||||
- Command documentation
|
||||
- Examples for AI agents
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
def _format_display_name(name: str) -> str:
|
||||
"""Format software name for display (replace underscores/hyphens with spaces, then title)."""
|
||||
return name.replace("_", " ").replace("-", " ").title()
|
||||
|
||||
|
||||
def _canonical_skill_name(harness_path: Path, software_name: str) -> str:
|
||||
"""Return the repo-root canonical skill id for a harness."""
|
||||
software_dir = software_name
|
||||
if harness_path.name == "agent-harness" and harness_path.parent.name:
|
||||
software_dir = harness_path.parent.name
|
||||
return f"cli-anything-{software_dir.replace('_', '-')}"
|
||||
|
||||
|
||||
def _click_declared_name(decorator_args: str, fallback: str) -> str:
|
||||
"""Return the Click command/group name declared in a decorator."""
|
||||
match = re.search(r'^\s*["\']([^"\']+)["\']', decorator_args)
|
||||
if not match:
|
||||
match = re.search(r'\bname\s*=\s*["\']([^"\']+)["\']', decorator_args)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return fallback.replace("_", "-")
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandInfo:
|
||||
"""Information about a CLI command."""
|
||||
name: str
|
||||
description: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandGroup:
|
||||
"""A group of related CLI commands."""
|
||||
name: str
|
||||
description: str
|
||||
commands: list[CommandInfo] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Example:
|
||||
"""An example of CLI usage."""
|
||||
title: str
|
||||
description: str
|
||||
code: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillMetadata:
|
||||
"""Metadata extracted from a CLI-Anything harness."""
|
||||
skill_name: str
|
||||
skill_description: str
|
||||
software_name: str
|
||||
skill_intro: str
|
||||
version: str
|
||||
system_package: Optional[str] = None
|
||||
command_groups: list[CommandGroup] = field(default_factory=list)
|
||||
examples: list[Example] = field(default_factory=list)
|
||||
|
||||
|
||||
def extract_cli_metadata(harness_path: str) -> SkillMetadata:
|
||||
"""
|
||||
Extract metadata from a CLI-Anything harness directory.
|
||||
|
||||
Args:
|
||||
harness_path: Path to the agent-harness directory
|
||||
|
||||
Returns:
|
||||
SkillMetadata containing extracted information
|
||||
"""
|
||||
harness_path = Path(harness_path)
|
||||
|
||||
# Find the cli_anything/<software> directory
|
||||
cli_anything_dir = harness_path / "cli_anything"
|
||||
if not cli_anything_dir.exists():
|
||||
raise ValueError(
|
||||
f"cli_anything directory not found in {harness_path}. "
|
||||
"Ensure the harness structure includes cli_anything/<software>/"
|
||||
)
|
||||
software_dirs = [d for d in cli_anything_dir.iterdir()
|
||||
if d.is_dir() and (d / "__init__.py").exists()]
|
||||
|
||||
if not software_dirs:
|
||||
raise ValueError(f"No CLI package found in {harness_path}")
|
||||
|
||||
software_dir = software_dirs[0]
|
||||
software_name = software_dir.name
|
||||
|
||||
# Extract metadata from README.md
|
||||
readme_path = software_dir / "README.md"
|
||||
skill_intro = ""
|
||||
system_package = None
|
||||
|
||||
if readme_path.exists():
|
||||
readme_content = readme_path.read_text(encoding="utf-8")
|
||||
skill_intro = extract_intro_from_readme(readme_content)
|
||||
system_package = extract_system_package(readme_content)
|
||||
|
||||
# Extract version from setup.py
|
||||
setup_path = harness_path / "setup.py"
|
||||
version = "1.0.0"
|
||||
|
||||
if setup_path.exists():
|
||||
version = extract_version_from_setup(setup_path)
|
||||
|
||||
# Extract commands from CLI file
|
||||
cli_file = software_dir / f"{software_name}_cli.py"
|
||||
command_groups = []
|
||||
|
||||
if cli_file.exists():
|
||||
command_groups = extract_commands_from_cli(cli_file)
|
||||
|
||||
# Generate examples based on software type
|
||||
examples = generate_examples(software_name, command_groups)
|
||||
|
||||
# Build skill name and description
|
||||
skill_name = _canonical_skill_name(harness_path, software_name)
|
||||
if skill_intro:
|
||||
intro_snippet = skill_intro[:100]
|
||||
suffix = "..." if len(skill_intro) > 100 else ""
|
||||
skill_description = f"Command-line interface for {_format_display_name(software_name)} - {intro_snippet}{suffix}"
|
||||
else:
|
||||
skill_description = f"Command-line interface for {_format_display_name(software_name)}"
|
||||
|
||||
return SkillMetadata(
|
||||
skill_name=skill_name,
|
||||
skill_description=skill_description,
|
||||
software_name=software_name,
|
||||
skill_intro=skill_intro,
|
||||
version=version,
|
||||
system_package=system_package,
|
||||
command_groups=command_groups,
|
||||
examples=examples
|
||||
)
|
||||
|
||||
|
||||
def extract_intro_from_readme(content: str) -> str:
|
||||
"""Extract introduction text from README content."""
|
||||
# Find the first paragraph after the title
|
||||
lines = content.split("\n")
|
||||
intro_lines = []
|
||||
in_intro = False
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
if in_intro and intro_lines:
|
||||
break
|
||||
continue
|
||||
if line.startswith("# "):
|
||||
in_intro = True
|
||||
continue
|
||||
if line.startswith("##"):
|
||||
break
|
||||
if in_intro:
|
||||
intro_lines.append(line)
|
||||
|
||||
return " ".join(intro_lines) or f"CLI interface for the software."
|
||||
|
||||
|
||||
def extract_system_package(content: str) -> Optional[str]:
|
||||
"""Extract system package installation command from README."""
|
||||
# Look for apt/brew install patterns
|
||||
patterns = [
|
||||
r"`apt install ([\w\-]+)`",
|
||||
r"`brew install ([\w\-]+)`",
|
||||
r"`apt-get install ([\w\-]+)`",
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, content)
|
||||
if match:
|
||||
package = match.group(1)
|
||||
if "apt-get" in pattern:
|
||||
return f"apt-get install {package}"
|
||||
elif "apt" in pattern:
|
||||
return f"apt install {package}"
|
||||
elif "brew" in pattern:
|
||||
return f"brew install {package}"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def extract_version_from_setup(setup_path: Path) -> str:
|
||||
"""Extract version from setup.py."""
|
||||
content = setup_path.read_text(encoding="utf-8")
|
||||
match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return "1.0.0"
|
||||
|
||||
|
||||
def extract_commands_from_cli(cli_path: Path) -> list[CommandGroup]:
|
||||
"""Extract command groups and commands from CLI file."""
|
||||
content = cli_path.read_text(encoding="utf-8")
|
||||
groups = []
|
||||
|
||||
# Find Click group decorators
|
||||
# Pattern handles:
|
||||
# - Multi-line decorators (decorators on separate lines)
|
||||
# - Docstrings on the same line or following line after function definition
|
||||
# - Various Click decorator patterns like @click.option(), @click.argument()
|
||||
# Uses re.DOTALL to match across newlines between decorator and def
|
||||
optional_decorator_pattern = r'(?:\s*@[\w.]+(?:\([^)]*\))?)*'
|
||||
|
||||
group_pattern = (
|
||||
r'@(\w+)\.group\(([^)]*)\)' # @xxx.group(...)
|
||||
+ optional_decorator_pattern # optional additional decorators
|
||||
+ r'\s*def\s+(\w+)\([^)]*\)' # def xxx(...):
|
||||
+ r':\s*' # colon with optional whitespace
|
||||
+ r'(?:"""([\s\S]*?)"""|\'\'\'([\s\S]*?)\'\'\')?' # optional docstring (""" or ''')
|
||||
)
|
||||
|
||||
group_lookup = {}
|
||||
group_display_paths = {}
|
||||
group_matches = list(re.finditer(group_pattern, content))
|
||||
root_group_funcs = {
|
||||
match.group(3).lower()
|
||||
for match in group_matches
|
||||
if match.group(1).lower() == "click"
|
||||
}
|
||||
|
||||
for match in group_matches:
|
||||
group_parent = match.group(1)
|
||||
group_args = match.group(2)
|
||||
group_func = match.group(3)
|
||||
# Docstring can be in group 4 (triple-double) or group 5 (triple-single)
|
||||
group_doc = (match.group(4) or match.group(5) or "").strip()
|
||||
|
||||
local_group_name = _format_display_name(_click_declared_name(group_args, group_func))
|
||||
parent_key = group_parent.lower()
|
||||
if parent_key == "click" or parent_key in root_group_funcs:
|
||||
group_path = [local_group_name]
|
||||
else:
|
||||
parent_path = group_display_paths.get(parent_key)
|
||||
if parent_path:
|
||||
group_path = [*parent_path, local_group_name]
|
||||
else:
|
||||
group_path = [_format_display_name(group_parent), local_group_name]
|
||||
|
||||
group_name = " ".join(group_path)
|
||||
|
||||
group = CommandGroup(
|
||||
name=group_name,
|
||||
description=group_doc or f"Commands for {group_name.lower()} operations.",
|
||||
commands=[]
|
||||
)
|
||||
groups.append(group)
|
||||
group_lookup[group_func.lower()] = group
|
||||
group_display_paths[group_func.lower()] = group_path
|
||||
|
||||
# Find Click command decorators
|
||||
# Pattern handles:
|
||||
# - Multi-line decorators (decorators on separate lines)
|
||||
# - Docstrings on the same line or following line after function definition
|
||||
# - Various Click decorator patterns like @click.option(), @click.argument()
|
||||
command_pattern = (
|
||||
r'@(\w+)\.command\(([^)]*)\)' # @xxx.command(...)
|
||||
+ optional_decorator_pattern # optional additional decorators
|
||||
+ r'\s*def\s+(\w+)\([^)]*\)' # def xxx(...):
|
||||
+ r':\s*' # colon with optional whitespace
|
||||
+ r'(?:"""([\s\S]*?)"""|\'\'\'([\s\S]*?)\'\'\')?' # optional docstring (""" or ''')
|
||||
)
|
||||
|
||||
for match in re.finditer(command_pattern, content):
|
||||
group_name = match.group(1)
|
||||
cmd_args = match.group(2)
|
||||
cmd_func = match.group(3)
|
||||
# Docstring can be in group 4 (triple-double) or group 5 (triple-single)
|
||||
cmd_doc = (match.group(4) or match.group(5) or "").strip()
|
||||
|
||||
# Find the matching group
|
||||
group = group_lookup.get(group_name.lower())
|
||||
if group:
|
||||
cmd_name = _click_declared_name(cmd_args, cmd_func)
|
||||
group.commands.append(CommandInfo(
|
||||
name=cmd_name,
|
||||
description=cmd_doc or f"Execute {cmd_func} operation."
|
||||
))
|
||||
|
||||
# If no groups found, create a default one with all commands
|
||||
if not groups:
|
||||
default_group = CommandGroup(
|
||||
name="General",
|
||||
description="General commands for the CLI.",
|
||||
commands=[]
|
||||
)
|
||||
|
||||
for match in re.finditer(command_pattern, content):
|
||||
cmd_args = match.group(2)
|
||||
cmd_func = match.group(3)
|
||||
# Docstring can be in group 4 (triple-double) or group 5 (triple-single)
|
||||
cmd_doc = (match.group(4) or match.group(5) or "").strip()
|
||||
cmd_name = _click_declared_name(cmd_args, cmd_func)
|
||||
default_group.commands.append(CommandInfo(
|
||||
name=cmd_name,
|
||||
description=cmd_doc or f"Execute {cmd_func} operation."
|
||||
))
|
||||
|
||||
if default_group.commands:
|
||||
groups.append(default_group)
|
||||
|
||||
return groups
|
||||
|
||||
|
||||
def generate_examples(software_name: str, command_groups: list[CommandGroup]) -> list[Example]:
|
||||
"""Generate usage examples based on software type and available commands."""
|
||||
examples = []
|
||||
|
||||
# Basic project creation example
|
||||
examples.append(Example(
|
||||
title="Create a New Project",
|
||||
description=f"Create a new {software_name} project file.",
|
||||
code=f"""cli-anything-{software_name} project new -o myproject.json
|
||||
# Or with JSON output for programmatic use
|
||||
cli-anything-{software_name} --json project new -o myproject.json"""
|
||||
))
|
||||
|
||||
# REPL usage example
|
||||
examples.append(Example(
|
||||
title="Interactive REPL Session",
|
||||
description="Start an interactive session with undo/redo support.",
|
||||
code=f"""cli-anything-{software_name}
|
||||
# Enter commands interactively
|
||||
# Use 'help' to see available commands
|
||||
# Use 'undo' and 'redo' for history navigation"""
|
||||
))
|
||||
|
||||
# Export example if export commands exist
|
||||
for group in command_groups:
|
||||
if "export" in group.name.lower():
|
||||
examples.append(Example(
|
||||
title="Export Project",
|
||||
description="Export the project to a final output format.",
|
||||
code=f"""cli-anything-{software_name} --project myproject.json export render output.pdf --overwrite"""
|
||||
))
|
||||
break
|
||||
|
||||
return examples
|
||||
|
||||
|
||||
def generate_skill_md(metadata: SkillMetadata, template_path: Optional[str] = None) -> str:
|
||||
"""
|
||||
Generate SKILL.md content from metadata using Jinja2 template.
|
||||
|
||||
Args:
|
||||
metadata: SkillMetadata containing CLI information
|
||||
template_path: Optional path to custom template file
|
||||
|
||||
Returns:
|
||||
Generated SKILL.md content as string
|
||||
"""
|
||||
try:
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
except ImportError:
|
||||
# Fallback to simple string formatting if Jinja2 not available
|
||||
return generate_skill_md_simple(metadata)
|
||||
|
||||
# Load template
|
||||
if template_path is None:
|
||||
template_path = Path(__file__).parent / "templates" / "SKILL.md.template"
|
||||
else:
|
||||
template_path = Path(template_path)
|
||||
|
||||
if not template_path.exists():
|
||||
return generate_skill_md_simple(metadata)
|
||||
|
||||
env = Environment(loader=FileSystemLoader(template_path.parent))
|
||||
template = env.get_template(template_path.name)
|
||||
|
||||
# Render template
|
||||
return template.render(
|
||||
skill_name=metadata.skill_name,
|
||||
skill_description=metadata.skill_description,
|
||||
software_name=metadata.software_name,
|
||||
skill_intro=metadata.skill_intro,
|
||||
version=metadata.version,
|
||||
system_package=metadata.system_package,
|
||||
command_groups=[{
|
||||
"name": g.name,
|
||||
"description": g.description,
|
||||
"commands": [{"name": c.name, "description": c.description} for c in g.commands]
|
||||
} for g in metadata.command_groups],
|
||||
examples=[{
|
||||
"title": e.title,
|
||||
"description": e.description,
|
||||
"code": e.code
|
||||
} for e in metadata.examples]
|
||||
)
|
||||
|
||||
|
||||
def generate_skill_md_simple(metadata: SkillMetadata) -> str:
|
||||
"""Generate SKILL.md without Jinja2 dependency."""
|
||||
lines = [
|
||||
"---",
|
||||
f'name: "{metadata.skill_name}"',
|
||||
f'description: "{metadata.skill_description}"',
|
||||
"---",
|
||||
"",
|
||||
f"# {metadata.skill_name}",
|
||||
"",
|
||||
metadata.skill_intro,
|
||||
"",
|
||||
"## Installation",
|
||||
"",
|
||||
f"This CLI is installed as part of the cli-anything-{metadata.software_name} package:",
|
||||
"",
|
||||
f"```bash",
|
||||
f"pip install cli-anything-{metadata.software_name}",
|
||||
f"```",
|
||||
"",
|
||||
"**Prerequisites:**",
|
||||
"- Python 3.10+",
|
||||
f"- {_format_display_name(metadata.software_name)} must be installed on your system",
|
||||
]
|
||||
|
||||
if metadata.system_package:
|
||||
lines.extend([
|
||||
f"- Install {metadata.software_name}: `{metadata.system_package}`"
|
||||
])
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"## Usage",
|
||||
"",
|
||||
"### Basic Commands",
|
||||
"",
|
||||
"```bash",
|
||||
"# Show help",
|
||||
f"cli-anything-{metadata.software_name} --help",
|
||||
"",
|
||||
"# Start interactive REPL mode",
|
||||
f"cli-anything-{metadata.software_name}",
|
||||
"",
|
||||
"# Create a new project",
|
||||
f"cli-anything-{metadata.software_name} project new -o project.json",
|
||||
"",
|
||||
"# Run with JSON output (for agent consumption)",
|
||||
f"cli-anything-{metadata.software_name} --json project info -p project.json",
|
||||
"```",
|
||||
"",
|
||||
])
|
||||
|
||||
# Add command groups
|
||||
if metadata.command_groups:
|
||||
lines.append("## Command Groups")
|
||||
lines.append("")
|
||||
|
||||
for group in metadata.command_groups:
|
||||
lines.append(f"### {group.name}")
|
||||
lines.append("")
|
||||
lines.append(group.description)
|
||||
lines.append("")
|
||||
|
||||
if group.commands:
|
||||
lines.append("| Command | Description |")
|
||||
lines.append("|---------|-------------|")
|
||||
for cmd in group.commands:
|
||||
lines.append(f"| `{cmd.name}` | {cmd.description} |")
|
||||
lines.append("")
|
||||
|
||||
# Add examples
|
||||
if metadata.examples:
|
||||
lines.append("## Examples")
|
||||
lines.append("")
|
||||
|
||||
for example in metadata.examples:
|
||||
lines.append(f"### {example.title}")
|
||||
lines.append("")
|
||||
lines.append(example.description)
|
||||
lines.append("")
|
||||
lines.append("```bash")
|
||||
lines.append(example.code)
|
||||
lines.append("```")
|
||||
lines.append("")
|
||||
|
||||
# Add AI agent guidance
|
||||
lines.extend([
|
||||
"## For AI Agents",
|
||||
"",
|
||||
"When using this CLI programmatically:",
|
||||
"",
|
||||
"1. **Always use `--json` flag** for parseable output",
|
||||
"2. **Check return codes** - 0 for success, non-zero for errors",
|
||||
"3. **Parse stderr** for error messages on failure",
|
||||
"4. **Use absolute paths** for all file operations",
|
||||
"5. **Verify outputs exist** after export operations",
|
||||
"",
|
||||
"## Version",
|
||||
"",
|
||||
metadata.version,
|
||||
])
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def generate_skill_file(harness_path: str, output_path: Optional[str] = None,
|
||||
template_path: Optional[str] = None) -> str:
|
||||
"""
|
||||
Generate a SKILL.md file for a CLI-Anything harness.
|
||||
|
||||
Args:
|
||||
harness_path: Path to the agent-harness directory
|
||||
output_path: Optional output path for SKILL.md
|
||||
(default: skills/cli-anything-<software>/SKILL.md)
|
||||
template_path: Optional path to custom Jinja2 template
|
||||
|
||||
Returns:
|
||||
Path to the generated SKILL.md file
|
||||
"""
|
||||
# Extract metadata
|
||||
metadata = extract_cli_metadata(harness_path)
|
||||
|
||||
# Generate content
|
||||
content = generate_skill_md(metadata, template_path)
|
||||
|
||||
# Determine output path
|
||||
harness_path_obj = Path(harness_path)
|
||||
compatibility_path = harness_path_obj / "cli_anything" / metadata.software_name / "skills" / "SKILL.md"
|
||||
if output_path is None:
|
||||
repo_root = harness_path_obj.parent.parent
|
||||
output_path = repo_root / "skills" / metadata.skill_name / "SKILL.md"
|
||||
else:
|
||||
output_path = Path(output_path)
|
||||
|
||||
# Ensure output directory exists
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write file
|
||||
output_path.write_text(content, encoding="utf-8")
|
||||
if compatibility_path != output_path:
|
||||
compatibility_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
compatibility_path.write_text(content, encoding="utf-8")
|
||||
|
||||
return str(output_path)
|
||||
|
||||
|
||||
# CLI interface for standalone usage
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate SKILL.md for CLI-Anything harnesses"
|
||||
)
|
||||
parser.add_argument(
|
||||
"harness_path",
|
||||
help="Path to the agent-harness directory"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o", "--output",
|
||||
help="Output path for SKILL.md (default: skills/cli-anything-<software>/SKILL.md)",
|
||||
default=None
|
||||
)
|
||||
parser.add_argument(
|
||||
"-t", "--template",
|
||||
help="Path to custom Jinja2 template",
|
||||
default=None
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
output_file = generate_skill_file(
|
||||
args.harness_path,
|
||||
args.output,
|
||||
args.template
|
||||
)
|
||||
|
||||
print(f"Generated: {output_file}")
|
||||
@@ -0,0 +1,123 @@
|
||||
---
|
||||
name: >-
|
||||
{{ skill_name }}
|
||||
description: >-
|
||||
{{ skill_description }}
|
||||
---
|
||||
|
||||
# {{ skill_name }}
|
||||
|
||||
{{ skill_intro }}
|
||||
|
||||
## Installation
|
||||
|
||||
This CLI is installed as part of the cli-anything-{{ software_name }} package:
|
||||
|
||||
```bash
|
||||
pip install cli-anything-{{ software_name }}
|
||||
```
|
||||
|
||||
**Prerequisites:**
|
||||
- Python 3.10+
|
||||
- {{ software_name }} must be installed on your system
|
||||
{% if system_package %}
|
||||
- Install {{ software_name }}: `{{ system_package }}`
|
||||
{% endif %}
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Commands
|
||||
|
||||
```bash
|
||||
# Show help
|
||||
cli-anything-{{ software_name }} --help
|
||||
|
||||
# Start interactive REPL mode
|
||||
cli-anything-{{ software_name }}
|
||||
|
||||
# Create a new project
|
||||
cli-anything-{{ software_name }} project new -o project.json
|
||||
|
||||
# Run with JSON output (for agent consumption)
|
||||
cli-anything-{{ software_name }} --json project info -p project.json
|
||||
```
|
||||
|
||||
### REPL Mode
|
||||
|
||||
When invoked without a subcommand, the CLI enters an interactive REPL session:
|
||||
|
||||
```bash
|
||||
cli-anything-{{ software_name }}
|
||||
# Enter commands interactively with tab-completion and history
|
||||
```
|
||||
|
||||
{% if command_groups %}
|
||||
## Command Groups
|
||||
|
||||
{% for group in command_groups %}
|
||||
### {{ group.name }}
|
||||
|
||||
{{ group.description }}
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
{% for cmd in group.commands %}
|
||||
| `{{ cmd.name }}` | {{ cmd.description }} |
|
||||
{% endfor %}
|
||||
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
## Examples
|
||||
|
||||
{% for example in examples %}
|
||||
### {{ example.title }}
|
||||
|
||||
{{ example.description }}
|
||||
|
||||
```bash
|
||||
{{ example.code }}
|
||||
```
|
||||
|
||||
{% endfor %}
|
||||
## State Management
|
||||
|
||||
The CLI maintains session state with:
|
||||
|
||||
- **Undo/Redo**: Up to 50 levels of history
|
||||
- **Project persistence**: Save/load project state as JSON
|
||||
- **Session tracking**: Track modifications and changes
|
||||
|
||||
## Output Formats
|
||||
|
||||
All commands support dual output modes:
|
||||
|
||||
- **Human-readable** (default): Tables, colors, formatted text
|
||||
- **Machine-readable** (`--json` flag): Structured JSON for agent consumption
|
||||
|
||||
```bash
|
||||
# Human output
|
||||
cli-anything-{{ software_name }} project info -p project.json
|
||||
|
||||
# JSON output for agents
|
||||
cli-anything-{{ software_name }} --json project info -p project.json
|
||||
```
|
||||
|
||||
## For AI Agents
|
||||
|
||||
When using this CLI programmatically:
|
||||
|
||||
1. **Always use `--json` flag** for parseable output
|
||||
2. **Check return codes** - 0 for success, non-zero for errors
|
||||
3. **Parse stderr** for error messages on failure
|
||||
4. **Use absolute paths** for all file operations
|
||||
5. **Verify outputs exist** after export operations
|
||||
|
||||
## More Information
|
||||
|
||||
- Full documentation: See README.md in the package
|
||||
- Test coverage: See TEST.md in the package
|
||||
- Methodology: See HARNESS.md in the cli-anything-plugin
|
||||
|
||||
## Version
|
||||
|
||||
{{ version }}
|
||||
@@ -0,0 +1,536 @@
|
||||
"""
|
||||
Tests for skill_generator.py — SKILL.md generation for CLI-Anything harnesses.
|
||||
|
||||
Verifies metadata extraction, SKILL.md generation, and edge cases.
|
||||
|
||||
Run with: pytest tests/test_skill_generator.py -v
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import textwrap
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Resolve skill_generator.py location:
|
||||
# - In the repo: cli-anything-plugin/skill_generator.py (parent dir)
|
||||
# - After install.sh: scripts/skill_generator.py (sibling dir)
|
||||
_PLUGIN_DIR = Path(__file__).resolve().parent.parent
|
||||
_SCRIPTS_DIR = _PLUGIN_DIR / "scripts"
|
||||
if (_SCRIPTS_DIR / "skill_generator.py").exists():
|
||||
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||
else:
|
||||
sys.path.insert(0, str(_PLUGIN_DIR))
|
||||
|
||||
from skill_generator import (
|
||||
extract_cli_metadata,
|
||||
generate_skill_md,
|
||||
generate_skill_md_simple,
|
||||
generate_skill_file,
|
||||
extract_intro_from_readme,
|
||||
extract_system_package,
|
||||
extract_version_from_setup,
|
||||
SkillMetadata,
|
||||
CommandInfo,
|
||||
CommandGroup,
|
||||
Example,
|
||||
)
|
||||
|
||||
|
||||
# ─── Fixtures ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def harness_dir(tmp_path):
|
||||
"""Create a minimal harness directory structure."""
|
||||
software = "testapp"
|
||||
cli_pkg = tmp_path / "cli_anything" / software
|
||||
cli_pkg.mkdir(parents=True)
|
||||
|
||||
# __init__.py
|
||||
(cli_pkg / "__init__.py").write_text('"""Test application CLI."""\n')
|
||||
|
||||
# README.md
|
||||
(cli_pkg / "README.md").write_text(
|
||||
textwrap.dedent(f"""\
|
||||
# {software}
|
||||
|
||||
A powerful test application for demonstrating CLI harness generation.
|
||||
This application supports batch processing and interactive use.
|
||||
""")
|
||||
)
|
||||
|
||||
# setup.py
|
||||
(tmp_path / "setup.py").write_text(
|
||||
textwrap.dedent("""\
|
||||
from setuptools import setup, find_packages
|
||||
setup(
|
||||
name="cli-anything-testapp",
|
||||
version="2.1.0",
|
||||
packages=find_packages(),
|
||||
)
|
||||
""")
|
||||
)
|
||||
|
||||
# CLI file with Click commands
|
||||
(cli_pkg / f"{software}_cli.py").write_text(
|
||||
textwrap.dedent("""\
|
||||
import click
|
||||
|
||||
@click.group()
|
||||
def cli():
|
||||
\"\"\"Main CLI group.\"\"\"
|
||||
pass
|
||||
|
||||
@cli.command()
|
||||
def export():
|
||||
\"\"\"Export data to file.\"\"\"
|
||||
pass
|
||||
|
||||
@cli.command()
|
||||
def import_data():
|
||||
\"\"\"Import data from file.\"\"\"
|
||||
pass
|
||||
""")
|
||||
)
|
||||
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def minimal_harness(tmp_path):
|
||||
"""Create the absolute minimal harness (just __init__.py)."""
|
||||
software = "minimal"
|
||||
cli_pkg = tmp_path / "cli_anything" / software
|
||||
cli_pkg.mkdir(parents=True)
|
||||
(cli_pkg / "__init__.py").write_text("")
|
||||
return tmp_path
|
||||
|
||||
|
||||
# ─── extract_cli_metadata Tests ────────────────────────────────────────
|
||||
|
||||
|
||||
class TestExtractCliMetadata:
|
||||
def test_extracts_software_name(self, harness_dir):
|
||||
metadata = extract_cli_metadata(str(harness_dir))
|
||||
assert metadata.software_name == "testapp"
|
||||
|
||||
def test_extracts_skill_name(self, harness_dir):
|
||||
metadata = extract_cli_metadata(str(harness_dir))
|
||||
assert metadata.skill_name == "cli-anything-testapp"
|
||||
|
||||
def test_extracts_version_from_setup_py(self, harness_dir):
|
||||
metadata = extract_cli_metadata(str(harness_dir))
|
||||
assert metadata.version == "2.1.0"
|
||||
|
||||
def test_extracts_intro_from_readme(self, harness_dir):
|
||||
metadata = extract_cli_metadata(str(harness_dir))
|
||||
assert "powerful test application" in metadata.skill_intro
|
||||
|
||||
def test_extracts_command_groups(self, harness_dir):
|
||||
metadata = extract_cli_metadata(str(harness_dir))
|
||||
assert len(metadata.command_groups) > 0
|
||||
|
||||
def test_extracts_commands_from_cli_file(self, harness_dir):
|
||||
metadata = extract_cli_metadata(str(harness_dir))
|
||||
all_commands = []
|
||||
for group in metadata.command_groups:
|
||||
all_commands.extend(group.commands)
|
||||
# Should find at least 'export' and 'import-data'
|
||||
cmd_names = [c.name for c in all_commands]
|
||||
assert "export" in cmd_names
|
||||
assert "import-data" in cmd_names
|
||||
|
||||
def test_respects_explicit_click_names(self, tmp_path):
|
||||
software = "named"
|
||||
cli_pkg = tmp_path / "cli_anything" / software
|
||||
cli_pkg.mkdir(parents=True)
|
||||
(cli_pkg / "__init__.py").write_text("")
|
||||
(cli_pkg / f"{software}_cli.py").write_text(
|
||||
textwrap.dedent("""\
|
||||
import click
|
||||
|
||||
@click.group()
|
||||
def cli():
|
||||
pass
|
||||
|
||||
@cli.group("remote-access")
|
||||
def remote_access_group():
|
||||
\"\"\"Remote access commands.\"\"\"
|
||||
pass
|
||||
|
||||
@remote_access_group.command("list-active")
|
||||
def list_active_sessions():
|
||||
\"\"\"List active sessions.\"\"\"
|
||||
pass
|
||||
|
||||
@cli.command(name="health-check")
|
||||
def health():
|
||||
\"\"\"Check service health.\"\"\"
|
||||
pass
|
||||
""")
|
||||
)
|
||||
|
||||
metadata = extract_cli_metadata(str(tmp_path))
|
||||
groups = {group.name: group for group in metadata.command_groups}
|
||||
|
||||
assert "Remote Access" in groups
|
||||
assert "Remote Access Group" not in groups
|
||||
assert [cmd.name for cmd in groups["Remote Access"].commands] == ["list-active"]
|
||||
cli_commands = [cmd.name for cmd in groups["Cli"].commands]
|
||||
assert "health-check" in cli_commands
|
||||
assert "health" not in cli_commands
|
||||
|
||||
def test_disambiguates_nested_declared_group_names(self, tmp_path):
|
||||
software = "nested"
|
||||
cli_pkg = tmp_path / "cli_anything" / software
|
||||
cli_pkg.mkdir(parents=True)
|
||||
(cli_pkg / "__init__.py").write_text("")
|
||||
(cli_pkg / f"{software}_cli.py").write_text(
|
||||
textwrap.dedent("""\
|
||||
import click
|
||||
|
||||
@click.group()
|
||||
def cli():
|
||||
pass
|
||||
|
||||
@cli.group("configs")
|
||||
def configs_group():
|
||||
\"\"\"Top-level config commands.\"\"\"
|
||||
pass
|
||||
|
||||
@configs_group.command("show")
|
||||
def show_config():
|
||||
\"\"\"Show config.\"\"\"
|
||||
pass
|
||||
|
||||
@cli.group("alerts")
|
||||
def alerts():
|
||||
\"\"\"Alert commands.\"\"\"
|
||||
pass
|
||||
|
||||
@alerts.group("configs")
|
||||
def alerts_configs():
|
||||
\"\"\"Alert config commands.\"\"\"
|
||||
pass
|
||||
|
||||
@alerts_configs.command("enable")
|
||||
def enable_alert_config():
|
||||
\"\"\"Enable alert config.\"\"\"
|
||||
pass
|
||||
""")
|
||||
)
|
||||
|
||||
metadata = extract_cli_metadata(str(tmp_path))
|
||||
groups = {group.name: group for group in metadata.command_groups}
|
||||
group_names = [group.name for group in metadata.command_groups]
|
||||
|
||||
assert group_names.count("Configs") == 1
|
||||
assert "Alerts Configs" in groups
|
||||
assert [cmd.name for cmd in groups["Configs"].commands] == ["show"]
|
||||
assert [cmd.name for cmd in groups["Alerts Configs"].commands] == ["enable"]
|
||||
|
||||
def test_preserves_top_level_group_names_with_decorated_root(self, tmp_path):
|
||||
software = "decorated"
|
||||
cli_pkg = tmp_path / "cli_anything" / software
|
||||
cli_pkg.mkdir(parents=True)
|
||||
(cli_pkg / "__init__.py").write_text("")
|
||||
(cli_pkg / f"{software}_cli.py").write_text(
|
||||
textwrap.dedent("""\
|
||||
import click
|
||||
|
||||
@click.group()
|
||||
@click.pass_context
|
||||
def cli(ctx):
|
||||
pass
|
||||
|
||||
@cli.group("devices")
|
||||
def devices_group():
|
||||
\"\"\"Device commands.\"\"\"
|
||||
pass
|
||||
|
||||
@devices_group.command("list")
|
||||
def list_devices():
|
||||
\"\"\"List devices.\"\"\"
|
||||
pass
|
||||
|
||||
@cli.group("alerts")
|
||||
def alerts():
|
||||
\"\"\"Alert commands.\"\"\"
|
||||
pass
|
||||
|
||||
@alerts.group("configs")
|
||||
def alerts_configs():
|
||||
\"\"\"Alert config commands.\"\"\"
|
||||
pass
|
||||
|
||||
@alerts_configs.command("enable")
|
||||
def enable_alert_config():
|
||||
\"\"\"Enable alert config.\"\"\"
|
||||
pass
|
||||
""")
|
||||
)
|
||||
|
||||
metadata = extract_cli_metadata(str(tmp_path))
|
||||
groups = {group.name: group for group in metadata.command_groups}
|
||||
|
||||
assert "Devices" in groups
|
||||
assert "Cli Devices" not in groups
|
||||
assert "Alerts Configs" in groups
|
||||
assert [cmd.name for cmd in groups["Devices"].commands] == ["list"]
|
||||
assert [cmd.name for cmd in groups["Alerts Configs"].commands] == ["enable"]
|
||||
|
||||
def test_generates_examples(self, harness_dir):
|
||||
metadata = extract_cli_metadata(str(harness_dir))
|
||||
assert len(metadata.examples) > 0
|
||||
|
||||
def test_minimal_harness(self, minimal_harness):
|
||||
metadata = extract_cli_metadata(str(minimal_harness))
|
||||
assert metadata.software_name == "minimal"
|
||||
assert metadata.version == "1.0.0" # Default when no setup.py
|
||||
|
||||
def test_raises_on_missing_cli_anything_dir(self, tmp_path):
|
||||
with pytest.raises(ValueError, match="cli_anything directory not found"):
|
||||
extract_cli_metadata(str(tmp_path))
|
||||
|
||||
def test_raises_on_empty_cli_anything_dir(self, tmp_path):
|
||||
(tmp_path / "cli_anything").mkdir()
|
||||
with pytest.raises(ValueError, match="No CLI package found"):
|
||||
extract_cli_metadata(str(tmp_path))
|
||||
|
||||
def test_description_contains_software_name(self, harness_dir):
|
||||
metadata = extract_cli_metadata(str(harness_dir))
|
||||
assert "testapp" in metadata.skill_description.lower() or "Testapp" in metadata.skill_description
|
||||
|
||||
|
||||
# ─── extract_version_from_setup Tests ──────────────────────────────────
|
||||
|
||||
|
||||
class TestExtractVersionFromSetup:
|
||||
def test_extracts_version(self, tmp_path):
|
||||
setup_py = tmp_path / "setup.py"
|
||||
setup_py.write_text('version="3.2.1"')
|
||||
assert extract_version_from_setup(setup_py) == "3.2.1"
|
||||
|
||||
def test_extracts_version_single_quotes(self, tmp_path):
|
||||
setup_py = tmp_path / "setup.py"
|
||||
setup_py.write_text("version='1.0.0'")
|
||||
assert extract_version_from_setup(setup_py) == "1.0.0"
|
||||
|
||||
def test_returns_default_when_no_version(self, tmp_path):
|
||||
setup_py = tmp_path / "setup.py"
|
||||
setup_py.write_text("# no version here")
|
||||
assert extract_version_from_setup(setup_py) == "1.0.0"
|
||||
|
||||
|
||||
# ─── extract_intro_from_readme Tests ───────────────────────────────────
|
||||
|
||||
|
||||
class TestExtractIntroFromReadme:
|
||||
def test_extracts_first_paragraph(self):
|
||||
content = "# My App\n\nThis is the intro paragraph.\n\n## Section\nMore text"
|
||||
intro = extract_intro_from_readme(content)
|
||||
assert "This is the intro paragraph" in intro
|
||||
|
||||
def test_returns_default_for_empty(self):
|
||||
content = "# Title\n## Section\n"
|
||||
intro = extract_intro_from_readme(content)
|
||||
assert "CLI interface" in intro
|
||||
|
||||
def test_handles_multiline_intro(self):
|
||||
content = "# App\nLine one.\nLine two.\n\n## Details"
|
||||
intro = extract_intro_from_readme(content)
|
||||
assert "Line one" in intro
|
||||
assert "Line two" in intro
|
||||
|
||||
|
||||
|
||||
# ─── extract_system_package Tests ─────────────────────
|
||||
|
||||
|
||||
class TestExtractSystemPackage:
|
||||
def test_apt_install(self):
|
||||
content = "Install with `apt install mytool`."
|
||||
result = extract_system_package(content)
|
||||
assert result == "apt install mytool"
|
||||
|
||||
def test_brew_install(self):
|
||||
content = "Install with `brew install mytool`."
|
||||
result = extract_system_package(content)
|
||||
assert result == "brew install mytool"
|
||||
|
||||
def test_apt_get_install_returns_apt_get_command(self):
|
||||
# Regression: apt-get pattern contains "apt" as a substring, so the
|
||||
# condition must check "apt-get" before "apt" to avoid returning the
|
||||
# wrong command ("apt install" instead of "apt-get install").
|
||||
content = "Install with `apt-get install mytool`."
|
||||
result = extract_system_package(content)
|
||||
assert result == "apt-get install mytool", (
|
||||
f"Expected 'apt-get install mytool', got {result!r}"
|
||||
)
|
||||
|
||||
def test_returns_none_when_no_match(self):
|
||||
content = "No installation instructions here."
|
||||
assert extract_system_package(content) is None
|
||||
|
||||
# ─── generate_skill_md Tests ───────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGenerateSkillMd:
|
||||
def _make_metadata(self, **overrides):
|
||||
defaults = dict(
|
||||
skill_name="cli-anything-testapp",
|
||||
skill_description="CLI for TestApp",
|
||||
software_name="testapp",
|
||||
skill_intro="A test application.",
|
||||
version="1.0.0",
|
||||
system_package=None,
|
||||
command_groups=[],
|
||||
examples=[],
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return SkillMetadata(**defaults)
|
||||
|
||||
def test_simple_output_has_yaml_frontmatter(self):
|
||||
metadata = self._make_metadata()
|
||||
content = generate_skill_md_simple(metadata)
|
||||
assert content.startswith("---")
|
||||
assert 'name: "cli-anything-testapp"' in content
|
||||
assert 'description: "CLI for TestApp"' in content
|
||||
|
||||
def test_simple_output_has_installation_section(self):
|
||||
metadata = self._make_metadata()
|
||||
content = generate_skill_md_simple(metadata)
|
||||
assert "## Installation" in content
|
||||
assert "pip install cli-anything-testapp" in content
|
||||
|
||||
def test_simple_output_includes_version(self):
|
||||
metadata = self._make_metadata(version="2.5.0")
|
||||
content = generate_skill_md_simple(metadata)
|
||||
assert "2.5.0" in content
|
||||
|
||||
def test_simple_output_has_command_groups(self):
|
||||
groups = [
|
||||
CommandGroup(
|
||||
name="Export",
|
||||
description="Export commands",
|
||||
commands=[
|
||||
CommandInfo(name="pdf", description="Export as PDF"),
|
||||
CommandInfo(name="svg", description="Export as SVG"),
|
||||
],
|
||||
)
|
||||
]
|
||||
metadata = self._make_metadata(command_groups=groups)
|
||||
content = generate_skill_md_simple(metadata)
|
||||
assert "### Export" in content
|
||||
assert "`pdf`" in content
|
||||
assert "`svg`" in content
|
||||
|
||||
def test_simple_output_has_examples(self):
|
||||
examples = [
|
||||
Example(
|
||||
title="Quick Start",
|
||||
description="Get started quickly",
|
||||
code="cli-anything-testapp --help",
|
||||
)
|
||||
]
|
||||
metadata = self._make_metadata(examples=examples)
|
||||
content = generate_skill_md_simple(metadata)
|
||||
assert "### Quick Start" in content
|
||||
assert "cli-anything-testapp --help" in content
|
||||
|
||||
def test_generate_skill_md_falls_back_to_simple(self):
|
||||
metadata = self._make_metadata()
|
||||
# Without a template file, should fall back to simple generation
|
||||
content = generate_skill_md(metadata, template_path="/nonexistent/template")
|
||||
assert "cli-anything-testapp" in content
|
||||
|
||||
def test_generate_skill_md_with_no_template_arg(self):
|
||||
metadata = self._make_metadata()
|
||||
# Should work without template_path argument (uses default or falls back)
|
||||
content = generate_skill_md(metadata)
|
||||
assert isinstance(content, str)
|
||||
assert len(content) > 0
|
||||
|
||||
|
||||
# ─── generate_skill_file Tests ─────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGenerateSkillFile:
|
||||
def test_generates_file_at_default_path(self, harness_dir):
|
||||
output = generate_skill_file(str(harness_dir))
|
||||
assert Path(output).exists()
|
||||
content = Path(output).read_text()
|
||||
assert "cli-anything-testapp" in content
|
||||
|
||||
def test_generates_file_at_custom_path(self, harness_dir, tmp_path):
|
||||
output_file = tmp_path / "custom" / "SKILL.md"
|
||||
output = generate_skill_file(str(harness_dir), str(output_file))
|
||||
assert Path(output).exists()
|
||||
content = Path(output).read_text()
|
||||
assert "testapp" in content.lower()
|
||||
|
||||
def test_creates_parent_directories(self, harness_dir, tmp_path):
|
||||
output_file = tmp_path / "deep" / "nested" / "dir" / "SKILL.md"
|
||||
output = generate_skill_file(str(harness_dir), str(output_file))
|
||||
assert Path(output).exists()
|
||||
|
||||
|
||||
# ─── Edge Case Tests ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
def test_harness_without_readme(self, tmp_path):
|
||||
"""Harness with no README.md should have empty intro."""
|
||||
software = "noreadme"
|
||||
cli_pkg = tmp_path / "cli_anything" / software
|
||||
cli_pkg.mkdir(parents=True)
|
||||
(cli_pkg / "__init__.py").write_text("")
|
||||
# No README.md, no setup.py, no CLI file
|
||||
|
||||
metadata = extract_cli_metadata(str(tmp_path))
|
||||
assert metadata.software_name == "noreadme"
|
||||
assert metadata.skill_intro == "" # No README → empty intro
|
||||
assert metadata.version == "1.0.0"
|
||||
assert metadata.command_groups == []
|
||||
# skill_description must not contain trailing " - ..." when intro is empty
|
||||
assert " - " not in metadata.skill_description
|
||||
assert not metadata.skill_description.endswith("...")
|
||||
|
||||
def test_harness_with_system_package(self, tmp_path):
|
||||
"""README with apt install instructions should extract system_package."""
|
||||
software = "syspkg"
|
||||
cli_pkg = tmp_path / "cli_anything" / software
|
||||
cli_pkg.mkdir(parents=True)
|
||||
(cli_pkg / "__init__.py").write_text("")
|
||||
(cli_pkg / "README.md").write_text(
|
||||
"# Syspkg\n\nInstall via `apt install syspkg-tool`.\n"
|
||||
)
|
||||
|
||||
metadata = extract_cli_metadata(str(tmp_path))
|
||||
assert metadata.system_package is not None
|
||||
assert "syspkg-tool" in metadata.system_package
|
||||
|
||||
def test_malformed_setup_py(self, tmp_path):
|
||||
"""Malformed setup.py should default to version 1.0.0."""
|
||||
software = "badsetup"
|
||||
cli_pkg = tmp_path / "cli_anything" / software
|
||||
cli_pkg.mkdir(parents=True)
|
||||
(cli_pkg / "__init__.py").write_text("")
|
||||
(tmp_path / "setup.py").write_text("THIS IS NOT VALID PYTHON { } }")
|
||||
|
||||
metadata = extract_cli_metadata(str(tmp_path))
|
||||
assert metadata.version == "1.0.0"
|
||||
|
||||
def test_empty_setup_py(self, tmp_path):
|
||||
"""Empty setup.py should default to 1.0.0."""
|
||||
software = "emptysetup"
|
||||
cli_pkg = tmp_path / "cli_anything" / software
|
||||
cli_pkg.mkdir(parents=True)
|
||||
(cli_pkg / "__init__.py").write_text("")
|
||||
(tmp_path / "setup.py").write_text("")
|
||||
|
||||
metadata = extract_cli_metadata(str(tmp_path))
|
||||
assert metadata.version == "1.0.0"
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
# Verify cli-anything plugin structure
|
||||
|
||||
echo "Verifying cli-anything plugin structure..."
|
||||
echo ""
|
||||
|
||||
ERRORS=0
|
||||
|
||||
# Check required files
|
||||
check_file() {
|
||||
if [ -f "$1" ]; then
|
||||
echo "✓ $1"
|
||||
else
|
||||
echo "✗ $1 (MISSING)"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
echo "Required files:"
|
||||
check_file ".claude-plugin/plugin.json"
|
||||
check_file "README.md"
|
||||
check_file "LICENSE"
|
||||
check_file "PUBLISHING.md"
|
||||
check_file "commands/cli-anything.md"
|
||||
check_file "commands/refine.md"
|
||||
check_file "commands/test.md"
|
||||
check_file "commands/validate.md"
|
||||
check_file "scripts/setup-cli-anything.sh"
|
||||
|
||||
echo ""
|
||||
echo "Checking plugin.json validity..."
|
||||
if python3 -c "import json; json.load(open('.claude-plugin/plugin.json'))" 2>/dev/null; then
|
||||
echo "✓ plugin.json is valid JSON"
|
||||
else
|
||||
echo "✗ plugin.json is invalid JSON"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Checking script permissions..."
|
||||
if [ -x "scripts/setup-cli-anything.sh" ]; then
|
||||
echo "✓ setup-cli-anything.sh is executable"
|
||||
else
|
||||
echo "✗ setup-cli-anything.sh is not executable"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
|
||||
echo ""
|
||||
if [ $ERRORS -eq 0 ]; then
|
||||
echo "✓ All checks passed! Plugin is ready."
|
||||
exit 0
|
||||
else
|
||||
echo "✗ $ERRORS error(s) found. Please fix before publishing."
|
||||
exit 1
|
||||
fi
|
||||
Reference in New Issue
Block a user