chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:55:23 +08:00
commit bc7eac8151
1701 changed files with 376767 additions and 0 deletions
@@ -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"
}
}
+747
View File
@@ -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 |
+201
View File
@@ -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.
+307
View File
@@ -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
+242
View File
@@ -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!
+462
View File
@@ -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))
+237
View File
@@ -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
+104
View File
@@ -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
+73
View File
@@ -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
+123
View File
@@ -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.
+64
View File
@@ -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 softwares 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.
+468
View File
@@ -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
+567
View File
@@ -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
View File
@@ -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 ""
+586
View File
@@ -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"
+55
View File
@@ -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