mirror of
https://github.com/HKUDS/CLI-Anything.git
synced 2026-10-30 18:12:56 +08:00
Merge branch 'main' into feature/add-sketch-harness
This commit is contained in:
+12
@@ -44,9 +44,12 @@
|
||||
!/sketch/
|
||||
!/drawio/
|
||||
!/mermaid/
|
||||
!/comfyui/
|
||||
!/adguardhome/
|
||||
!/novita/
|
||||
!/ollama/
|
||||
!/browser/
|
||||
!/musescore/
|
||||
|
||||
# Step 5: Inside each software dir, ignore everything (including dotfiles)
|
||||
/gimp/*
|
||||
@@ -77,10 +80,16 @@
|
||||
/drawio/.*
|
||||
/mermaid/*
|
||||
/mermaid/.*
|
||||
/comfyui/*
|
||||
/comfyui/.*
|
||||
/adguardhome/*
|
||||
/adguardhome/.*
|
||||
/ollama/*
|
||||
/ollama/.*
|
||||
/browser/*
|
||||
/browser/.*
|
||||
/musescore/*
|
||||
/musescore/.*
|
||||
|
||||
# Step 6: ...except agent-harness/
|
||||
!/gimp/agent-harness/
|
||||
@@ -97,9 +106,12 @@
|
||||
!/sketch/agent-harness/
|
||||
!/drawio/agent-harness/
|
||||
!/mermaid/agent-harness/
|
||||
!/comfyui/agent-harness/
|
||||
!/adguardhome/agent-harness/
|
||||
!/novita/agent-harness/
|
||||
!/ollama/agent-harness/
|
||||
!/browser/agent-harness/
|
||||
!/musescore/agent-harness/
|
||||
|
||||
# Step 7: Ignore build artifacts within allowed dirs
|
||||
**/__pycache__/
|
||||
|
||||
@@ -84,7 +84,7 @@ CLI is the universal interface for both humans and AI agents:
|
||||
|
||||
- **Python 3.10+**
|
||||
- Target software installed (e.g., GIMP, Blender, LibreOffice, or your own application)
|
||||
- A supported AI coding agent: [Claude Code](#-claude-code) | [OpenClaw](#-openclaw) | [OpenCode](#-opencode) | [Codex](#-codex) | [Qodercli](#-qodercli) | [More Platforms](#-more-platforms-coming-soon)
|
||||
- A supported AI coding agent: [Claude Code](#-claude-code) | [OpenClaw](#-openclaw) | [OpenCode](#-opencode) | [Codex](#-codex) | [Qodercli](#-qodercli) | [GitHub Copilot CLI](#-github-copilot-cli) | [More Platforms](#-more-platforms-coming-soon)
|
||||
|
||||
### Pick Your Platform
|
||||
|
||||
@@ -327,6 +327,30 @@ The Codex skill adapts the same methodology used by the Claude Code plugin and
|
||||
OpenCode commands, while keeping the generated Python harness format unchanged.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><h4 id="-github-copilot-cli">⚡ GitHub Copilot CLI <sup><code>Community</code></sup></h4></summary>
|
||||
|
||||
**Step 1: Install the Plugin**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/HKUDS/CLI-Anything.git
|
||||
cd CLI-Anything
|
||||
copilot plugin install ./cli-anything-plugin
|
||||
```
|
||||
|
||||
This installs the CLI-Anything plugin to GitHub Copilot CLI. The plugin should now be available in your GitHub Copilot CLI session.
|
||||
|
||||
**Step 2: Use CLI-Anything from GitHub Copilot CLI**
|
||||
|
||||
```bash
|
||||
/cli-anything:cli-anything ./gimp
|
||||
/cli-anything:refine ./gimp "batch processing and filters"
|
||||
/cli-anything:validate ./gimp
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h4 id="-more-platforms-coming-soon">🔮 More Platforms (Coming Soon)</h4></summary>
|
||||
|
||||
@@ -547,6 +571,13 @@ Each application received complete, production-ready CLI interfaces — not demo
|
||||
<td align="center">✅ 161</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><strong>🌐 Browser</strong></td>
|
||||
<td>Browser Automation</td>
|
||||
<td><code>cli-anything-browser</code></td>
|
||||
<td>DOMShell MCP + Accessibility Tree</td>
|
||||
<td align="center">✅ <a href="browser/agent-harness/">New</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><strong>📄 LibreOffice</strong></td>
|
||||
<td>Office Suite (Writer, Calc, Impress)</td>
|
||||
<td><code>cli-anything-libreoffice</code></td>
|
||||
@@ -589,6 +620,13 @@ Each application received complete, production-ready CLI interfaces — not demo
|
||||
<td align="center">✅ 22</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><strong>🎵 MuseScore</strong></td>
|
||||
<td>Music Notation</td>
|
||||
<td><code>cli-anything-musescore</code></td>
|
||||
<td>mscore CLI (MSCX/MusicXML)</td>
|
||||
<td align="center">✅ 56</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><strong>📐 Draw.io</strong></td>
|
||||
<td>Diagramming</td>
|
||||
<td><code>cli-anything-drawio</code></td>
|
||||
@@ -739,18 +777,19 @@ cli-anything/
|
||||
├── 🧊 blender/agent-harness/ # Blender CLI (208 tests)
|
||||
├── ✏️ inkscape/agent-harness/ # Inkscape CLI (202 tests)
|
||||
├── 🎵 audacity/agent-harness/ # Audacity CLI (161 tests)
|
||||
├── 🌐 browser/agent-harness/ # Browser CLI (DOMShell MCP, new)
|
||||
├── 📄 libreoffice/agent-harness/ # LibreOffice CLI (158 tests)
|
||||
├── 📝 mubu/agent-harness/ # Mubu CLI (96 tests)
|
||||
├── 📹 obs-studio/agent-harness/ # OBS Studio CLI (153 tests)
|
||||
├── 🎞️ kdenlive/agent-harness/ # Kdenlive CLI (155 tests)
|
||||
├── 🎬 shotcut/agent-harness/ # Shotcut CLI (154 tests)
|
||||
├── 📞 zoom/agent-harness/ # Zoom CLI (22 tests)
|
||||
├── 🎵 musescore/agent-harness/ # MuseScore CLI (56 tests)
|
||||
├── 📐 drawio/agent-harness/ # Draw.io CLI (138 tests)
|
||||
├── 🧜 mermaid/agent-harness/ # Mermaid Live Editor CLI (10 tests)
|
||||
├── ✨ anygen/agent-harness/ # AnyGen CLI (50 tests)
|
||||
├── 🖼️ comfyui/agent-harness/ # ComfyUI CLI (70 tests)
|
||||
├── 🧠 notebooklm/agent-harness/ # NotebookLM CLI (experimental, 21 tests)
|
||||
├── 🖼️ comfyui/agent-harness/ # ComfyUI CLI (70 tests)
|
||||
├── 🛡️ adguardhome/agent-harness/ # AdGuard Home CLI (36 tests)
|
||||
├── 🦙 ollama/agent-harness/ # Ollama CLI (98 tests)
|
||||
└── 🎨 sketch/agent-harness/ # Sketch CLI (19 tests, Node.js)
|
||||
|
||||
+25
-1
@@ -58,7 +58,7 @@ CLI 是人类和 AI Agent 共通的万能接口:
|
||||
|
||||
- **Python 3.10+**
|
||||
- 目标软件已安装(如 GIMP、Blender、LibreOffice 或你自己的应用)
|
||||
- 支持的 AI 编程工具之一:[Claude Code](#-claude-code) | [OpenClaw](#-openclaw) | [OpenCode](#-opencode) | [Codex](#-codex) | [Qodercli](#-qodercli) | [更多平台](#-更多平台即将支持)
|
||||
- 支持的 AI 编程工具之一:[Claude Code](#-claude-code) | [OpenClaw](#-openclaw) | [OpenCode](#-opencode) | [Codex](#-codex) | [Qodercli](#-qodercli) | [GitHub Copilot CLI](#-github-copilot-cli) | [更多平台](#-更多平台即将支持)
|
||||
|
||||
### 选择你的平台
|
||||
|
||||
@@ -279,6 +279,30 @@ Use CLI-Anything to validate ./libreoffice
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><h4 id="-github-copilot-cli">⚡ GitHub Copilot CLI <sup><code>社区贡献</code></sup></h4></summary>
|
||||
|
||||
**第一步:安装插件**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/HKUDS/CLI-Anything.git
|
||||
cd CLI-Anything
|
||||
copilot plugin install ./cli-anything-plugin
|
||||
```
|
||||
|
||||
这将 CLI-Anything 插件安装到 GitHub Copilot CLI 中。插件应该已经在你的 GitHub Copilot CLI 会话中可用了。
|
||||
|
||||
**第二步:在 GitHub Copilot CLI 中使用 CLI-Anything**
|
||||
|
||||
```bash
|
||||
/cli-anything:cli-anything ./gimp
|
||||
/cli-anything:refine ./gimp "批处理和滤镜"
|
||||
/cli-anything:validate ./gimp
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h4 id="-更多平台即将支持">🔮 更多平台(即将支持)</h4></summary>
|
||||
|
||||
|
||||
+25
-1
@@ -58,7 +58,7 @@ CLIは人間とAIエージェント両方にとって普遍的なインターフ
|
||||
|
||||
- **Python 3.10+**
|
||||
- 対象ソフトウェアがインストール済みであること(例:GIMP、Blender、LibreOffice、または独自のアプリケーション)
|
||||
- サポートされているAIコーディングエージェント: [Claude Code](#-claude-code) | [OpenClaw](#-openclaw) | [OpenCode](#-opencode) | [Codex](#-codex) | [Qodercli](#-qodercli) | [その他のプラットフォーム](#-その他のプラットフォーム近日公開)
|
||||
- サポートされているAIコーディングエージェント: [Claude Code](#-claude-code) | [OpenClaw](#-openclaw) | [OpenCode](#-opencode) | [Codex](#-codex) | [Qodercli](#-qodercli) | [GitHub Copilot CLI](#-github-copilot-cli) | [その他のプラットフォーム](#-その他のプラットフォーム近日公開)
|
||||
|
||||
### プラットフォームを選択
|
||||
|
||||
@@ -272,6 +272,30 @@ CLI-Anythingを使って./libreofficeを検証して
|
||||
CodexスキルはClaude CodeプラグインおよびOpenCodeコマンドと同じ方法論を適用しつつ、生成されるPythonハーネスのフォーマットは変更されません。
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><h4 id="-github-copilot-cli">⚡ GitHub Copilot CLI <sup><code>コミュニティ</code></sup></h4></summary>
|
||||
|
||||
**ステップ1: プラグインのインストール**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/HKUDS/CLI-Anything.git
|
||||
cd CLI-Anything
|
||||
copilot plugin install ./cli-anything-plugin
|
||||
```
|
||||
|
||||
これにより、CLI-Anything プラグインが GitHub Copilot CLI にインストールされます。プラグインはすでに GitHub Copilot CLI セッションで利用できるはずです。
|
||||
|
||||
**ステップ2: GitHub Copilot CLIからCLI-Anythingを使用**
|
||||
|
||||
```bash
|
||||
/cli-anything:cli-anything ./gimp
|
||||
/cli-anything:refine ./gimp "バッチ処理とフィルタ"
|
||||
/cli-anything:validate ./gimp
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h4 id="-その他のプラットフォーム近日公開">🔮 その他のプラットフォーム(近日公開)</h4></summary>
|
||||
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
---
|
||||
name: >-
|
||||
cli-anything-adguardhome
|
||||
description: >-
|
||||
Command-line interface for AdGuard Home - Network-wide ad blocking and DNS management via AdGuard Home REST API. Designed for AI agents and power users who need to manage filtering, DNS rewrites, clients, DHCP, and query logs without a GUI.
|
||||
---
|
||||
|
||||
# cli-anything-adguardhome
|
||||
|
||||
Network-wide ad blocking and DNS management via the AdGuard Home REST API. Designed for AI agents and power users who need to manage filtering, DNS rewrites, clients, DHCP, and query logs without a GUI.
|
||||
|
||||
## Installation
|
||||
|
||||
This CLI is installed as part of the cli-anything-adguardhome package:
|
||||
|
||||
```bash
|
||||
pip install cli-anything-adguardhome
|
||||
```
|
||||
|
||||
**Prerequisites:**
|
||||
- Python 3.10+
|
||||
- AdGuard Home must be installed and running
|
||||
- Install AdGuard Home: `curl -s -S -L https://raw.githubusercontent.com/AdguardTeam/AdGuardHome/master/scripts/install.sh | sh -s -- -v`
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Commands
|
||||
|
||||
```bash
|
||||
# Show help
|
||||
cli-anything-adguardhome --help
|
||||
|
||||
# Start interactive REPL mode
|
||||
cli-anything-adguardhome
|
||||
|
||||
# Check server status
|
||||
cli-anything-adguardhome server status
|
||||
|
||||
# Run with JSON output (for agent consumption)
|
||||
cli-anything-adguardhome --json server status
|
||||
```
|
||||
|
||||
### REPL Mode
|
||||
|
||||
When invoked without a subcommand, the CLI enters an interactive REPL session:
|
||||
|
||||
```bash
|
||||
cli-anything-adguardhome
|
||||
# Enter commands interactively with tab-completion and history
|
||||
```
|
||||
|
||||
## Command Groups
|
||||
|
||||
### Config
|
||||
|
||||
Connection and configuration management.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `show` | Show current connection configuration |
|
||||
| `save` | Save connection settings to a config file |
|
||||
| `test` | Test the connection to AdGuard Home |
|
||||
|
||||
### Server
|
||||
|
||||
Server status and control commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `status` | Show server protection status |
|
||||
| `version` | Show AdGuard Home version |
|
||||
| `restart` | Restart the AdGuard Home server |
|
||||
|
||||
### Filter
|
||||
|
||||
DNS filter list management.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `list` | List all configured filter lists |
|
||||
| `status` | Show filtering status |
|
||||
| `toggle` | Enable or disable filtering globally |
|
||||
| `add` | Add a new filter list by URL |
|
||||
| `remove` | Remove a filter list |
|
||||
| `enable` | Enable a specific filter list |
|
||||
| `disable` | Disable a specific filter list |
|
||||
| `refresh` | Force-refresh all filter lists |
|
||||
|
||||
### Blocking
|
||||
|
||||
Parental control, safe browsing, and safe search settings.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `parental status` | Show parental control status |
|
||||
| `parental enable` | Enable parental control |
|
||||
| `parental disable` | Disable parental control |
|
||||
| `safebrowsing status` | Show safe browsing status |
|
||||
| `safebrowsing enable` | Enable safe browsing |
|
||||
| `safebrowsing disable` | Disable safe browsing |
|
||||
| `safesearch status` | Show safe search status |
|
||||
| `safesearch enable` | Enable safe search |
|
||||
| `safesearch disable` | Disable safe search |
|
||||
|
||||
### Blocked-Services
|
||||
|
||||
Manage blocked internet services.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `list` | List currently blocked services |
|
||||
| `set` | Set the list of blocked services |
|
||||
|
||||
### Clients
|
||||
|
||||
Client device management.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `list` | List all configured clients |
|
||||
| `add` | Add a new client by name and IP |
|
||||
| `remove` | Remove a client |
|
||||
| `show` | Show details for a specific client |
|
||||
|
||||
### Stats
|
||||
|
||||
Query statistics.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `show` | Show DNS query statistics |
|
||||
| `reset` | Reset all statistics |
|
||||
| `config` | View or update statistics retention interval |
|
||||
|
||||
### Log
|
||||
|
||||
DNS query log management.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `show` | Show recent DNS query log entries |
|
||||
| `config` | View or update query log settings |
|
||||
| `clear` | Clear the query log |
|
||||
|
||||
### Rewrite
|
||||
|
||||
DNS rewrite rules.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `list` | List all DNS rewrite rules |
|
||||
| `add` | Add a DNS rewrite rule |
|
||||
| `remove` | Remove a DNS rewrite rule |
|
||||
|
||||
### DHCP
|
||||
|
||||
DHCP server management.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `status` | Show DHCP server status |
|
||||
| `leases` | List active DHCP leases |
|
||||
| `add-static` | Add a static DHCP lease |
|
||||
| `remove-static` | Remove a static DHCP lease |
|
||||
|
||||
### TLS
|
||||
|
||||
TLS/HTTPS configuration.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `status` | Show TLS configuration status |
|
||||
|
||||
## Examples
|
||||
|
||||
### Check Server Status
|
||||
|
||||
```bash
|
||||
cli-anything-adguardhome server status
|
||||
cli-anything-adguardhome server version
|
||||
```
|
||||
|
||||
### Manage Filter Lists
|
||||
|
||||
```bash
|
||||
# List current filters
|
||||
cli-anything-adguardhome filter list
|
||||
|
||||
# Add a new blocklist
|
||||
cli-anything-adguardhome filter add --url https://somehost.com/list.txt --name "My List"
|
||||
|
||||
# Refresh all filters
|
||||
cli-anything-adguardhome filter refresh
|
||||
```
|
||||
|
||||
### DNS Rewrites
|
||||
|
||||
```bash
|
||||
# Add a local DNS entry
|
||||
cli-anything-adguardhome rewrite add --domain "myserver.local" --answer "192.168.1.50"
|
||||
|
||||
# List all rewrites
|
||||
cli-anything-adguardhome rewrite list
|
||||
```
|
||||
|
||||
### Client Management
|
||||
|
||||
```bash
|
||||
cli-anything-adguardhome clients add --name "My PC" --ip 192.168.1.100
|
||||
cli-anything-adguardhome clients list
|
||||
```
|
||||
|
||||
### Query Statistics
|
||||
|
||||
```bash
|
||||
# Show stats (human-readable)
|
||||
cli-anything-adguardhome stats show
|
||||
|
||||
# Show stats (JSON for agents)
|
||||
cli-anything-adguardhome --json stats show
|
||||
```
|
||||
|
||||
## Output Formats
|
||||
|
||||
All commands support dual output modes:
|
||||
|
||||
- **Human-readable** (default): Tables, colors, formatted text
|
||||
- **Machine-readable** (`--json` flag): Structured JSON for agent consumption
|
||||
|
||||
```bash
|
||||
# Human output
|
||||
cli-anything-adguardhome filter list
|
||||
|
||||
# JSON output for agents
|
||||
cli-anything-adguardhome --json filter list
|
||||
```
|
||||
|
||||
## For AI Agents
|
||||
|
||||
When using this CLI programmatically:
|
||||
|
||||
1. **Always use `--json` flag** for parseable output
|
||||
2. **Check return codes** - 0 for success, non-zero for errors
|
||||
3. **Parse stderr** for error messages on failure
|
||||
4. **Use absolute paths** for all file operations
|
||||
5. **Test connection first** with `config test` before other commands
|
||||
|
||||
## More Information
|
||||
|
||||
- Full documentation: See README.md in the package
|
||||
- Test coverage: See TEST.md in the package
|
||||
- Methodology: See HARNESS.md in the cli-anything-plugin
|
||||
|
||||
## Version
|
||||
|
||||
1.0.0
|
||||
@@ -15,5 +15,9 @@ setup(
|
||||
"cli-anything-adguardhome=cli_anything.adguardhome.adguardhome_cli:main",
|
||||
],
|
||||
},
|
||||
package_data={
|
||||
"cli_anything.adguardhome": ["skills/*.md"],
|
||||
},
|
||||
include_package_data=True,
|
||||
python_requires=">=3.10",
|
||||
)
|
||||
|
||||
@@ -19,6 +19,7 @@ Usage:
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import shlex
|
||||
import click
|
||||
from typing import Optional
|
||||
|
||||
@@ -759,7 +760,10 @@ def repl(project_path):
|
||||
_repl_help(skin)
|
||||
continue
|
||||
|
||||
args = line.split()
|
||||
try:
|
||||
args = shlex.split(line)
|
||||
except ValueError:
|
||||
args = line.split()
|
||||
try:
|
||||
cli.main(args, standalone_mode=False)
|
||||
except SystemExit:
|
||||
|
||||
@@ -17,6 +17,7 @@ Usage:
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import shlex
|
||||
import click
|
||||
from typing import Optional
|
||||
|
||||
@@ -896,8 +897,11 @@ def repl(project_path):
|
||||
skin.help(_repl_commands)
|
||||
continue
|
||||
|
||||
# Parse and execute command
|
||||
args = line.split()
|
||||
# Parse and execute command (shlex handles quoted strings with spaces)
|
||||
try:
|
||||
args = shlex.split(line)
|
||||
except ValueError:
|
||||
args = line.split()
|
||||
try:
|
||||
cli.main(args, standalone_mode=False)
|
||||
except SystemExit:
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
# Browser Harness: DOMShell MCP Integration
|
||||
|
||||
## Purpose
|
||||
|
||||
This harness provides browser automation using [DOMShell](https://github.com/apireno/DOMShell)'s MCP server. DOMShell maps Chrome's Accessibility Tree to a virtual filesystem, enabling filesystem-first browser automation with familiar shell commands (`ls`, `cd`, `cat`, `grep`, `click`).
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ CLI Commands │────▶│ browser_cli.py │────▶│ MCP Backend │
|
||||
│ (Click groups) │ │ (CLI entry) │ │ (domshell_ │
|
||||
└─────────────────┘ └─────────────────┘ │ backend.py) │
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌─────────────────────────────────────┼────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌───────────────┐ ┌────────────┐ ┌──────────┐
|
||||
│ Spawn npx │ │ DOMShell │ │ Chrome │
|
||||
│ subprocess │◀──stdio─────────▶│ MCP Server│◀───│ + Ext │
|
||||
└───────────────┘ └────────────┘ └──────────┘
|
||||
|
||||
State Management:
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ _session: Session │
|
||||
│ - current_url: str │
|
||||
│ - working_dir: str (path in accessibility tree) │
|
||||
│ - history: list[str] (for back/forward) │
|
||||
│ - daemon_mode: bool (persistent connection) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## DOMShell MCP Server
|
||||
|
||||
DOMShell is an npm package that exposes Chrome's Accessibility Tree via MCP:
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Verify DOMShell is available
|
||||
npx @apireno/domshell --version
|
||||
|
||||
# Install Chrome extension
|
||||
# https://chromewebstore.google.com/detail/domshell
|
||||
```
|
||||
|
||||
### MCP Tools
|
||||
|
||||
DOMShell exposes these MCP tools:
|
||||
|
||||
| Tool | Description | CLI Command |
|
||||
|------|-------------|-------------|
|
||||
| `domshell_ls` | List directory contents | `fs ls` |
|
||||
| `domshell_cd` | Change directory | `fs cd` |
|
||||
| `domshell_cat` | Read element content | `fs cat` |
|
||||
| `domshell_grep` | Search for pattern | `fs grep` |
|
||||
| `domshell_click` | Click element | `act click` |
|
||||
| `domshell_type` | Type text | `act type` |
|
||||
| `domshell_open` | Navigate to URL | `page open` |
|
||||
| `domshell_reload` | Reload page | `page reload` |
|
||||
| `domshell_back` | Navigate back | `page back` |
|
||||
| `domshell_forward` | Navigate forward | `page forward` |
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### 1. MCP Backend Pattern (First in CLI-Anything)
|
||||
|
||||
This is the first CLI-Anything harness to use an MCP server as a backend.
|
||||
|
||||
**Backend wrapper** (`domshell_backend.py`):
|
||||
- Uses `mcp` Python SDK with `stdio` transport
|
||||
- Spawns `npx @apireno/domshell` subprocess per command
|
||||
- Async MCP interface wrapped in sync functions via `asyncio.run()`
|
||||
|
||||
**Session management**:
|
||||
- MCP server is stateless (spawned per command)
|
||||
- CLI maintains state (URL, working directory, history)
|
||||
- Daemon mode (`--daemon`) provides persistent connection
|
||||
|
||||
### 2. Daemon Mode
|
||||
|
||||
By default, each CLI command spawns a new MCP server process. This is simple but adds latency (~1-3s cold start).
|
||||
|
||||
**Daemon mode** (`--daemon` flag):
|
||||
- Spawns MCP server once, reuses connection
|
||||
- Much faster for interactive use
|
||||
- Requires explicit `daemon-start`/`daemon-stop`
|
||||
|
||||
### 3. State Model
|
||||
|
||||
**Page state** (not project state):
|
||||
- `current_url`: Currently loaded page
|
||||
- `working_dir`: Current path in accessibility tree
|
||||
- `history`: Back navigation stack
|
||||
- `forward_stack`: Forward navigation stack
|
||||
|
||||
**No persistence**: State is in-memory only. Accessibility tree structure changes when pages update, so saving paths would be fragile.
|
||||
|
||||
### 4. Filesystem-First Commands
|
||||
|
||||
DOMShell's key insight: **filesystem primitives outperform DOM queries** for agents.
|
||||
|
||||
Compare:
|
||||
```
|
||||
# DOM query approach (selector-based)
|
||||
await page.querySelector("#main button[type='submit']")
|
||||
|
||||
# Filesystem approach
|
||||
ls /main
|
||||
grep "submit"
|
||||
click /main/button[0]
|
||||
```
|
||||
|
||||
The filesystem approach is more discoverable and composable.
|
||||
|
||||
## Chrome DevTools Protocol
|
||||
|
||||
DOMShell uses Chrome's DevTools Protocol to access the Accessibility Tree:
|
||||
|
||||
### Accessibility Tree vs DOM
|
||||
|
||||
The Accessibility Tree is a simplified view of the DOM that:
|
||||
- Filters out structural elements (divs, spans without semantic meaning)
|
||||
- Includes computed accessible names and roles
|
||||
- Flattens complex structures
|
||||
- Provides stable IDs for screen readers
|
||||
|
||||
**Why use Accessibility Tree:**
|
||||
- Stable: Page updates don't change structure as much as DOM
|
||||
- Semantic: Roles and names are what screen readers use
|
||||
- Agent-friendly: Flatter, simpler to navigate
|
||||
|
||||
**Tradeoffs:**
|
||||
- Less granular than full DOM (can't access arbitrary divs)
|
||||
- Chrome-dependent (requires extension)
|
||||
|
||||
## Path Syntax
|
||||
|
||||
DOMShell uses a filesystem-like path syntax:
|
||||
|
||||
```
|
||||
/ — Root (document)
|
||||
/main — Main landmark (role="main")
|
||||
/main/div[0] — First div in main
|
||||
/main/div[0]/button[2] — Third button in first div
|
||||
```
|
||||
|
||||
### Array Indexing
|
||||
|
||||
- **0-based**: `button[0]` is the first button
|
||||
- **Relative paths**: `..` goes up one level
|
||||
- **Root**: `/` is always the document root
|
||||
|
||||
### Special Paths
|
||||
|
||||
- `.` — Current directory
|
||||
- `..` — Parent directory
|
||||
- `/` — Root (document)
|
||||
|
||||
## Command Groups
|
||||
|
||||
### Page Commands (`page`)
|
||||
|
||||
| Command | Description | State Impact |
|
||||
|---------|-------------|--------------|
|
||||
| `open <url>` | Navigate to URL | Sets `current_url`, resets `working_dir` |
|
||||
| `reload` | Reload current page | None |
|
||||
| `back` | Navigate back | Pops `history`, pushes to `forward_stack` |
|
||||
| `forward` | Navigate forward | Pops `forward_stack`, pushes to `history` |
|
||||
| `info` | Show page info | None |
|
||||
|
||||
### Filesystem Commands (`fs`)
|
||||
|
||||
| Command | Description | State Impact |
|
||||
|---------|-------------|--------------|
|
||||
| `ls [path]` | List elements | None |
|
||||
| `cd <path>` | Change directory | Sets `working_dir` |
|
||||
| `cat [path]` | Read element | None |
|
||||
| `grep <pat> [path]` | Search | None |
|
||||
| `pwd` | Print working dir | None |
|
||||
|
||||
### Action Commands (`act`)
|
||||
|
||||
| Command | Description | State Impact |
|
||||
|---------|-------------|--------------|
|
||||
| `click <path>` | Click element | May trigger navigation |
|
||||
| `type <path> <text>` | Type text | None |
|
||||
|
||||
### Session Commands (`session`)
|
||||
|
||||
| Command | Description | State Impact |
|
||||
|---------|-------------|--------------|
|
||||
| `status` | Show session state | None |
|
||||
| `daemon-start` | Start daemon mode | Sets `daemon_mode=True` |
|
||||
| `daemon-stop` | Stop daemon mode | Sets `daemon_mode=False` |
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Dependency Checks
|
||||
|
||||
The CLI checks dependencies at startup:
|
||||
|
||||
```python
|
||||
available, message = is_available()
|
||||
if not available:
|
||||
print(f"Error: {message}")
|
||||
# Install instructions...
|
||||
```
|
||||
|
||||
**Error messages:**
|
||||
- "npx not found" → Install Node.js
|
||||
- "DOMShell not found" → Run `npx @apireno/domshell --version`
|
||||
- "DOMShell MCP call failed" → Install Chrome extension
|
||||
|
||||
### MCP Tool Failures
|
||||
|
||||
MCP tool failures raise `RuntimeError` with context:
|
||||
|
||||
```python
|
||||
try:
|
||||
result = await session.call_tool(tool_name, arguments)
|
||||
except Exception as e:
|
||||
raise RuntimeError(
|
||||
f"DOMShell MCP call failed: {e}\n"
|
||||
f"Ensure Chrome is running with DOMShell extension."
|
||||
)
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests (`test_core.py`)
|
||||
|
||||
- Mock MCP backend responses
|
||||
- Test path resolution logic (`..`, relative paths)
|
||||
- Test state management (history, working_dir)
|
||||
- No Chrome required
|
||||
|
||||
### E2E Tests (`test_full_e2e.py`)
|
||||
|
||||
- Requires Chrome + DOMShell extension
|
||||
- Test real web pages (example.com, etc.)
|
||||
- Verify accessibility tree structure
|
||||
- Test daemon lifecycle
|
||||
|
||||
### Test Scenarios
|
||||
|
||||
1. **Basic navigation**: Open → ls → cd → ls
|
||||
2. **Search and act**: Open → grep → click
|
||||
3. **Form interaction**: Open → type → click submit
|
||||
4. **Daemon mode**: Start → ls → cd → stop
|
||||
5. **Error paths**: Missing dependencies, invalid paths
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Per-Command Overhead
|
||||
|
||||
Each command spawns `npx @apireno/domshell`:
|
||||
- **Cold start**: 1-3 seconds (first run, package download)
|
||||
- **Warm start**: ~100-500ms (subsequent runs)
|
||||
|
||||
**Mitigation**: Use daemon mode for interactive sessions.
|
||||
|
||||
### Accessibility Tree Size
|
||||
|
||||
Complex pages may have thousands of accessible elements:
|
||||
- `ls /` on a large page could return 1000+ entries
|
||||
- Use specific paths to limit results
|
||||
- `grep` is more efficient than `ls` for finding elements
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
**Not in scope for V1:**
|
||||
- Screenshot capture
|
||||
- Wait-for-element commands
|
||||
- Form fill helper (bulk)
|
||||
- Headless Chrome mode
|
||||
- Multi-browser support (Firefox, Safari)
|
||||
- Concurrent MCP operations (batch commands)
|
||||
|
||||
## References
|
||||
|
||||
- [DOMShell GitHub](https://github.com/apireno/DOMShell)
|
||||
- [DOMShell Benchmark](https://github.com/apireno/DOMShell/tree/main/experiments/claude_domshell_vs_cic)
|
||||
- [Chrome Accessibility Tree](https://developer.chrome.com/docs/accessibility/tree)
|
||||
- [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk)
|
||||
- [CLI-Anything HARNESS.md](https://github.com/HKUDS/CLI-Anything/tree/main/cli-anything-plugin/HARNESS.md)
|
||||
|
||||
## Applying This Pattern
|
||||
|
||||
The MCP backend pattern can be applied to any software that exposes an MCP server:
|
||||
|
||||
| Software | MCP Server | Transport | Use Case |
|
||||
|----------|------------|-----------|----------|
|
||||
| DOMShell | `@apireno/domshell` | stdio | Browser automation |
|
||||
| (future) | Various | stdio/SSE | Any MCP-compatible service |
|
||||
|
||||
**Pattern:**
|
||||
1. Identify MCP server and tools
|
||||
2. Create backend wrapper with `mcp` SDK
|
||||
3. Map tools to CLI commands
|
||||
4. Maintain state on CLI side (MCP is stateless per command)
|
||||
5. Optional: Add daemon mode for persistent connection
|
||||
@@ -0,0 +1,264 @@
|
||||
# cli-anything-browser — Browser Automation CLI
|
||||
|
||||
A command-line interface for browser automation using [DOMShell](https://github.com/apireno/DOMShell)'s MCP server. Maps Chrome's Accessibility Tree to a virtual filesystem for agent-native browser automation.
|
||||
|
||||
## Features
|
||||
|
||||
- **Filesystem-first navigation**: Use `ls`, `cd`, `cat` to explore web pages
|
||||
- **Search**: `grep` for text patterns in the accessibility tree
|
||||
- **Actions**: `click`, `type` to interact with elements
|
||||
- **JSON output**: `--json` flag for machine-readable output
|
||||
- **Interactive REPL**: Stateful session with command history
|
||||
- **Daemon mode**: Optional persistent connection for faster interactive use
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. **Node.js and npx** (required for DOMShell MCP server):
|
||||
```bash
|
||||
# Install Node.js from https://nodejs.org/
|
||||
node --version
|
||||
npx --version
|
||||
```
|
||||
|
||||
2. **Chrome/Chromium** with DOMShell extension:
|
||||
- Install DOMShell from [Chrome Web Store](https://chromewebstore.google.com/detail/domshell-%E2%80%94-browser-filesy/okcliheamhmijccjknkkplploacoidnp)
|
||||
- Ensure Chrome is running before using the CLI
|
||||
|
||||
3. **Python 3.10+**:
|
||||
```bash
|
||||
python --version
|
||||
```
|
||||
|
||||
### Install CLI
|
||||
|
||||
```bash
|
||||
cd browser/agent-harness
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
Verify installation:
|
||||
```bash
|
||||
cli-anything-browser --help
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### One-Shot Commands
|
||||
|
||||
```bash
|
||||
# Open a page
|
||||
cli-anything-browser page open https://example.com
|
||||
|
||||
# List elements at root
|
||||
cli-anything-browser fs ls /
|
||||
|
||||
# Navigate into a section
|
||||
cli-anything-browser fs cd /main
|
||||
|
||||
# List elements in current directory
|
||||
cli-anything-browser fs ls
|
||||
|
||||
# Read element content
|
||||
cli-anything-browser fs cat /main/button[0]
|
||||
|
||||
# Search for text
|
||||
cli-anything-browser fs grep "Login"
|
||||
|
||||
# Click an element
|
||||
cli-anything-browser act click /main/button[0]
|
||||
|
||||
# Type into an input
|
||||
cli-anything-browser act type /main/input[0] "Hello, World!"
|
||||
|
||||
# Get page info
|
||||
cli-anything-browser page info
|
||||
|
||||
# Navigate back/forward
|
||||
cli-anything-browser page back
|
||||
cli-anything-browser page forward
|
||||
```
|
||||
|
||||
**Note:** One-shot commands each start with a fresh session (no URL or working directory). For stateful workflows (like `cd` followed by `ls` without a path), use the REPL instead.
|
||||
|
||||
### JSON Output
|
||||
|
||||
```bash
|
||||
# Get machine-readable output
|
||||
cli-anything-browser --json fs ls /
|
||||
|
||||
# Returns:
|
||||
{
|
||||
"path": "/",
|
||||
"entries": [
|
||||
{
|
||||
"name": "main",
|
||||
"role": "landmark",
|
||||
"path": "/main"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Daemon Mode (Persistent Connection)
|
||||
|
||||
For faster interactive use, start daemon mode within a REPL session:
|
||||
|
||||
```bash
|
||||
# Start REPL with daemon mode
|
||||
cli-anything-browser --daemon
|
||||
|
||||
# Or start daemon within REPL
|
||||
session daemon-start
|
||||
|
||||
# Run commands (uses persistent connection)
|
||||
fs ls /
|
||||
fs cd /main
|
||||
|
||||
# Stop daemon when done
|
||||
session daemon-stop
|
||||
```
|
||||
|
||||
**Note:** Daemon mode only works within a single running process (REPL or `--daemon` flag). State does not persist across separate CLI invocations.
|
||||
|
||||
### Interactive REPL
|
||||
|
||||
Run without arguments to enter interactive mode:
|
||||
|
||||
```bash
|
||||
cli-anything-browser
|
||||
```
|
||||
|
||||
REPL commands:
|
||||
- `page open <url>` — Open a URL
|
||||
- `fs ls [path]` — List elements
|
||||
- `fs cd <path>` — Change directory
|
||||
- `fs cat [path]` — Read element
|
||||
- `fs grep <pattern>` — Search for text
|
||||
- `fs pwd` — Print working directory
|
||||
- `act click <path>` — Click element
|
||||
- `act type <path> <text>` — Type text
|
||||
- `session status` — Show session state
|
||||
- `help` — Show commands
|
||||
- `quit` — Exit REPL
|
||||
|
||||
## Command Groups
|
||||
|
||||
### `page` — Page Navigation
|
||||
- `open <url>` — Navigate to URL
|
||||
- `reload` — Reload current page
|
||||
- `back` — Navigate back in history
|
||||
- `forward` — Navigate forward in history
|
||||
- `info` — Show current page info
|
||||
|
||||
### `fs` — Filesystem Commands
|
||||
- `ls [path]` — List elements at path
|
||||
- `cd <path>` — Change directory
|
||||
- `cat [path]` — Read element content
|
||||
- `grep <pattern> [path]` — Search for text
|
||||
- `pwd` — Print working directory
|
||||
|
||||
### `act` — Action Commands
|
||||
- `click <path>` — Click an element
|
||||
- `type <path> <text>` — Type text into input
|
||||
|
||||
### `session` — Session Management
|
||||
- `status` — Show session status
|
||||
- `daemon-start` — Start persistent daemon mode
|
||||
- `daemon-stop` — Stop daemon mode
|
||||
|
||||
## Path Syntax
|
||||
|
||||
DOMShell uses a filesystem-like path syntax for the Accessibility Tree:
|
||||
|
||||
```
|
||||
/ — Root (page)
|
||||
/main — Main landmark
|
||||
/main/div[0] — First div in main
|
||||
/main/div[0]/button[2] — Third button in first div
|
||||
```
|
||||
|
||||
Array indices are 0-based. Use relative paths with `..` to go up.
|
||||
|
||||
## Examples
|
||||
|
||||
### Basic Navigation
|
||||
```bash
|
||||
# Open a page
|
||||
cli-anything-browser page open https://example.com
|
||||
|
||||
# Explore structure
|
||||
cli-anything-browser fs ls /
|
||||
cli-anything-browser fs cd /main
|
||||
cli-anything-browser fs ls
|
||||
|
||||
# Go back to root
|
||||
cli-anything-browser fs cd /
|
||||
```
|
||||
|
||||
### Search and Click
|
||||
```bash
|
||||
# Open page and search for login button
|
||||
cli-anything-browser page open https://example.com/login
|
||||
cli-anything-browser fs grep "Login"
|
||||
|
||||
# Click the login button (adjust path as needed)
|
||||
cli-anything-browser act click /main/button[0]
|
||||
```
|
||||
|
||||
### Form Fill
|
||||
```bash
|
||||
# Type into form fields
|
||||
cli-anything-browser act type /main/input[0] "user@example.com"
|
||||
cli-anything-browser act type /main/input[1] "password123"
|
||||
|
||||
# Click submit
|
||||
cli-anything-browser act click /main/button[0]
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Run tests:
|
||||
|
||||
```bash
|
||||
# Unit tests (no Chrome required)
|
||||
pytest cli_anything/browser/tests/test_core.py -v
|
||||
|
||||
# E2E tests (requires Chrome + DOMShell)
|
||||
pytest cli_anything/browser/tests/test_full_e2e.py -v
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "npx not found"
|
||||
Install Node.js from https://nodejs.org/
|
||||
|
||||
### "DOMShell not found"
|
||||
Run: `npx @apireno/domshell --version` (first run downloads the package)
|
||||
|
||||
### "DOMShell MCP call failed"
|
||||
- Ensure Chrome is running
|
||||
- Install DOMShell extension from Chrome Web Store
|
||||
- Check that DOMShell is enabled in Chrome
|
||||
|
||||
### Commands hang on first use
|
||||
First `npx` call downloads DOMShell package (10-50 MB). Subsequent calls are faster. Use `--daemon` mode for persistent connection.
|
||||
|
||||
## Architecture
|
||||
|
||||
This CLI follows the [CLI-Anything harness methodology](https://github.com/HKUDS/CLI-Anything/tree/main/cli-anything-plugin/HARNESS.md):
|
||||
|
||||
- **Backend**: DOMShell MCP server via stdio transport
|
||||
- **State**: Page state (URL, working directory, navigation history)
|
||||
- **Pattern**: Filesystem-first commands map to Accessibility Tree
|
||||
|
||||
## Links
|
||||
|
||||
- [DOMShell GitHub](https://github.com/apireno/DOMShell)
|
||||
- [CLI-Anything](https://github.com/HKUDS/CLI-Anything)
|
||||
- [Issue #90](https://github.com/HKUDS/CLI-Anything/issues/90)
|
||||
|
||||
## License
|
||||
|
||||
MIT License — See [CLI-Anything](https://github.com/HKUDS/CLI-Anything) for details.
|
||||
@@ -0,0 +1,7 @@
|
||||
"""cli-anything-browser — Browser automation via DOMShell MCP server.
|
||||
|
||||
This package provides filesystem-first browser automation using Chrome's
|
||||
Accessibility Tree exposed through DOMShell's MCP server.
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Allow running as python -m cli_anything.browser"""
|
||||
from cli_anything.browser.browser_cli import main
|
||||
main()
|
||||
@@ -0,0 +1,431 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Browser CLI — A command-line interface for browser automation via DOMShell MCP.
|
||||
|
||||
This CLI provides filesystem-first browser automation using Chrome's Accessibility Tree.
|
||||
Navigate web pages using familiar shell commands: ls, cd, cat, grep, click.
|
||||
|
||||
Usage:
|
||||
# One-shot commands
|
||||
cli-anything-browser page open https://example.com
|
||||
cli-anything-browser fs ls /
|
||||
cli-anything-browser act click /main/button[0]
|
||||
cli-anything-browser --json fs cat /main/title
|
||||
|
||||
# Interactive REPL
|
||||
cli-anything-browser
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import shlex
|
||||
import click
|
||||
from typing import Optional
|
||||
|
||||
from cli_anything.browser.core.session import Session
|
||||
from cli_anything.browser.core import page as page_mod
|
||||
from cli_anything.browser.core import fs as fs_mod
|
||||
from cli_anything.browser.utils import domshell_backend as backend
|
||||
|
||||
# Global state
|
||||
_session: Optional[Session] = None
|
||||
_json_output = False
|
||||
_repl_mode = False
|
||||
_availability_cached: Optional[tuple[bool, str]] = None # Cache for REPL mode
|
||||
|
||||
|
||||
def get_session() -> Session:
|
||||
global _session
|
||||
if _session is None:
|
||||
_session = Session()
|
||||
return _session
|
||||
|
||||
|
||||
def output(data, message: str = ""):
|
||||
if _json_output:
|
||||
click.echo(json.dumps(data, indent=2, default=str))
|
||||
else:
|
||||
if message:
|
||||
click.echo(message)
|
||||
if isinstance(data, dict):
|
||||
_print_dict(data)
|
||||
elif isinstance(data, list):
|
||||
_print_list(data)
|
||||
else:
|
||||
click.echo(str(data))
|
||||
|
||||
|
||||
def _print_dict(d: dict, indent: int = 0):
|
||||
prefix = " " * indent
|
||||
for k, v in d.items():
|
||||
if isinstance(v, dict):
|
||||
click.echo(f"{prefix}{k}:")
|
||||
_print_dict(v, indent + 1)
|
||||
elif isinstance(v, list):
|
||||
click.echo(f"{prefix}{k}:")
|
||||
_print_list(v, indent + 1)
|
||||
else:
|
||||
click.echo(f"{prefix}{k}: {v}")
|
||||
|
||||
|
||||
def _print_list(items: list, indent: int = 0):
|
||||
prefix = " " * indent
|
||||
for i, item in enumerate(items):
|
||||
if isinstance(item, dict):
|
||||
click.echo(f"{prefix}[{i}]")
|
||||
_print_dict(item, indent + 1)
|
||||
else:
|
||||
click.echo(f"{prefix}- {item}")
|
||||
|
||||
|
||||
def handle_error(func):
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except RuntimeError as e:
|
||||
if _json_output:
|
||||
click.echo(json.dumps({"error": str(e), "type": "runtime_error"}))
|
||||
else:
|
||||
click.echo(f"Error: {e}", err=True)
|
||||
if not _repl_mode:
|
||||
sys.exit(1)
|
||||
except (ValueError, IndexError) as e:
|
||||
if _json_output:
|
||||
click.echo(json.dumps({"error": str(e), "type": type(e).__name__}))
|
||||
else:
|
||||
click.echo(f"Error: {e}", err=True)
|
||||
if not _repl_mode:
|
||||
sys.exit(1)
|
||||
wrapper.__name__ = func.__name__
|
||||
wrapper.__doc__ = func.__doc__
|
||||
return wrapper
|
||||
|
||||
|
||||
# ── Main CLI Group ──────────────────────────────────────────────
|
||||
@click.group(invoke_without_command=True)
|
||||
@click.option("--json", "use_json", is_flag=True, help="Output as JSON")
|
||||
@click.option("--daemon", "use_daemon", is_flag=True,
|
||||
help="Use persistent daemon mode (faster for interactive use)")
|
||||
@click.pass_context
|
||||
def cli(ctx, use_json, use_daemon):
|
||||
"""Browser CLI — Filesystem-first browser automation via DOMShell.
|
||||
|
||||
Run without a subcommand to enter interactive REPL mode.
|
||||
"""
|
||||
global _json_output, _session, _availability_cached
|
||||
_json_output = use_json
|
||||
|
||||
# Check DOMShell availability (skip for help/version to allow viewing docs without DOMShell)
|
||||
# Cache the result for REPL mode to avoid repeated npx subprocess spawns
|
||||
if '--help' not in sys.argv and '--version' not in sys.argv:
|
||||
if _availability_cached is None:
|
||||
_availability_cached = backend.is_available()
|
||||
available, msg = _availability_cached
|
||||
if not available:
|
||||
if _json_output:
|
||||
click.echo(json.dumps({"error": msg, "type": "dependency_error"}))
|
||||
else:
|
||||
click.echo(f"Error: {msg}", err=True)
|
||||
click.echo(
|
||||
"\nInstall DOMShell Chrome extension:\n"
|
||||
" https://chromewebstore.google.com/detail/domshell"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Initialize session with daemon mode
|
||||
_session = get_session()
|
||||
if use_daemon:
|
||||
try:
|
||||
backend.start_daemon()
|
||||
_session.enable_daemon()
|
||||
if not _json_output:
|
||||
click.echo("Daemon mode: persistent MCP connection active")
|
||||
except RuntimeError as e:
|
||||
if _json_output:
|
||||
click.echo(json.dumps({"error": str(e), "type": "daemon_error"}))
|
||||
else:
|
||||
click.echo(f"Daemon start failed: {e}", err=True)
|
||||
click.echo("Falling back to per-command mode", err=True)
|
||||
|
||||
if ctx.invoked_subcommand is None:
|
||||
ctx.invoke(repl)
|
||||
|
||||
|
||||
# ── Page Commands ───────────────────────────────────────────────
|
||||
@cli.group()
|
||||
def page():
|
||||
"""Page navigation commands."""
|
||||
pass
|
||||
|
||||
|
||||
@page.command("open")
|
||||
@click.argument("url")
|
||||
@handle_error
|
||||
def page_open(url):
|
||||
"""Open a URL in Chrome."""
|
||||
sess = get_session()
|
||||
result = page_mod.open_page(sess, url)
|
||||
output(result, f"Opened: {url}")
|
||||
|
||||
|
||||
@page.command("reload")
|
||||
@handle_error
|
||||
def page_reload():
|
||||
"""Reload the current page."""
|
||||
sess = get_session()
|
||||
result = page_mod.reload_page(sess)
|
||||
output(result, "Page reloaded")
|
||||
|
||||
|
||||
@page.command("back")
|
||||
@handle_error
|
||||
def page_back():
|
||||
"""Navigate back in history."""
|
||||
sess = get_session()
|
||||
result = page_mod.go_back(sess)
|
||||
if "error" in result:
|
||||
output(result, result["error"])
|
||||
else:
|
||||
output(result, "Navigated back")
|
||||
|
||||
|
||||
@page.command("forward")
|
||||
@handle_error
|
||||
def page_forward():
|
||||
"""Navigate forward in history."""
|
||||
sess = get_session()
|
||||
result = page_mod.go_forward(sess)
|
||||
if "error" in result:
|
||||
output(result, result["error"])
|
||||
else:
|
||||
output(result, "Navigated forward")
|
||||
|
||||
|
||||
@page.command("info")
|
||||
@handle_error
|
||||
def page_info():
|
||||
"""Show current page information."""
|
||||
sess = get_session()
|
||||
result = page_mod.get_page_info(sess)
|
||||
output(result)
|
||||
|
||||
|
||||
# ── Filesystem Commands ──────────────────────────────────────────
|
||||
@cli.group()
|
||||
def fs():
|
||||
"""Filesystem navigation commands (Accessibility Tree)."""
|
||||
pass
|
||||
|
||||
|
||||
@fs.command("ls")
|
||||
@click.argument("path", default="", required=False)
|
||||
@handle_error
|
||||
def fs_ls(path):
|
||||
"""List elements at a path in the accessibility tree."""
|
||||
sess = get_session()
|
||||
result = fs_mod.list_elements(sess, path)
|
||||
if _json_output:
|
||||
output(result)
|
||||
else:
|
||||
entries = result.get("entries", [])
|
||||
if not entries:
|
||||
click.echo(f"No elements at {path or sess.working_dir}")
|
||||
return
|
||||
click.echo(f"{'NAME':<40} {'ROLE':<20} {'PATH'}")
|
||||
click.echo("─" * 80)
|
||||
for entry in entries:
|
||||
name = entry.get("name", "")
|
||||
role = entry.get("role", "")
|
||||
entry_path = entry.get("path", "")
|
||||
click.echo(f"{name:<40} {role:<20} {entry_path}")
|
||||
|
||||
|
||||
@fs.command("cd")
|
||||
@click.argument("path")
|
||||
@handle_error
|
||||
def fs_cd(path):
|
||||
"""Change directory in the accessibility tree."""
|
||||
sess = get_session()
|
||||
result = fs_mod.change_directory(sess, path)
|
||||
if "error" in result:
|
||||
output(result, result["error"])
|
||||
else:
|
||||
output(result, f"Changed to: {sess.working_dir}")
|
||||
|
||||
|
||||
@fs.command("cat")
|
||||
@click.argument("path", default="", required=False)
|
||||
@handle_error
|
||||
def fs_cat(path):
|
||||
"""Read element content from the accessibility tree."""
|
||||
sess = get_session()
|
||||
result = fs_mod.read_element(sess, path)
|
||||
output(result)
|
||||
|
||||
|
||||
@fs.command("grep")
|
||||
@click.argument("pattern")
|
||||
@click.argument("path", default="", required=False)
|
||||
@handle_error
|
||||
def fs_grep(pattern, path):
|
||||
"""Search for pattern in the accessibility tree."""
|
||||
sess = get_session()
|
||||
result = fs_mod.grep_elements(sess, pattern, path)
|
||||
if _json_output:
|
||||
output(result)
|
||||
else:
|
||||
matches = result.get("matches", [])
|
||||
if not matches:
|
||||
click.echo(f"No matches for '{pattern}'")
|
||||
return
|
||||
click.echo(f"Matches for '{pattern}':")
|
||||
for match in matches:
|
||||
click.echo(f" {match}")
|
||||
|
||||
|
||||
@fs.command("pwd")
|
||||
@handle_error
|
||||
def fs_pwd():
|
||||
"""Print current working directory in accessibility tree."""
|
||||
sess = get_session()
|
||||
click.echo(sess.working_dir)
|
||||
|
||||
|
||||
# ── Action Commands ──────────────────────────────────────────────
|
||||
@cli.group()
|
||||
def act():
|
||||
"""Action commands on elements."""
|
||||
pass
|
||||
|
||||
|
||||
@act.command("click")
|
||||
@click.argument("path")
|
||||
@handle_error
|
||||
def act_click(path):
|
||||
"""Click an element at the given path."""
|
||||
sess = get_session()
|
||||
use_daemon = sess.daemon_mode
|
||||
result = backend.click(path, use_daemon=use_daemon)
|
||||
output(result, f"Clicked: {path}")
|
||||
|
||||
|
||||
@act.command("type")
|
||||
@click.argument("path")
|
||||
@click.argument("text")
|
||||
@handle_error
|
||||
def act_type(path, text):
|
||||
"""Type text into an input element."""
|
||||
sess = get_session()
|
||||
use_daemon = sess.daemon_mode
|
||||
result = backend.type_text(path, text, use_daemon=use_daemon)
|
||||
output(result, f"Typed into: {path}")
|
||||
|
||||
|
||||
# ── Session Commands ─────────────────────────────────────────────
|
||||
@cli.group()
|
||||
def session():
|
||||
"""Session management commands."""
|
||||
pass
|
||||
|
||||
|
||||
@session.command("status")
|
||||
@handle_error
|
||||
def session_status():
|
||||
"""Show current session status."""
|
||||
sess = get_session()
|
||||
status = sess.status()
|
||||
output(status)
|
||||
|
||||
|
||||
@session.command("daemon-start")
|
||||
@handle_error
|
||||
def session_daemon_start():
|
||||
"""Start persistent daemon mode."""
|
||||
try:
|
||||
backend.start_daemon()
|
||||
get_session().enable_daemon()
|
||||
output({"daemon": "started"}, "Daemon mode started")
|
||||
except RuntimeError as e:
|
||||
output({"error": str(e)}, str(e))
|
||||
|
||||
|
||||
@session.command("daemon-stop")
|
||||
@handle_error
|
||||
def session_daemon_stop():
|
||||
"""Stop persistent daemon mode."""
|
||||
backend.stop_daemon()
|
||||
get_session().disable_daemon()
|
||||
output({"daemon": "stopped"}, "Daemon mode stopped")
|
||||
|
||||
|
||||
# ── REPL ─────────────────────────────────────────────────────────
|
||||
@cli.command()
|
||||
@handle_error
|
||||
def repl():
|
||||
"""Start interactive REPL session."""
|
||||
from cli_anything.browser.utils.repl_skin import ReplSkin
|
||||
|
||||
global _repl_mode
|
||||
_repl_mode = True
|
||||
|
||||
skin = ReplSkin("browser", version="1.0.0")
|
||||
skin.print_banner()
|
||||
|
||||
pt_session = skin.create_prompt_session()
|
||||
|
||||
_repl_commands = {
|
||||
"page": "open|reload|back|forward|info",
|
||||
"fs": "ls|cd|cat|grep|pwd",
|
||||
"act": "click|type",
|
||||
"session": "status|daemon-start|daemon-stop",
|
||||
"help": "Show this help",
|
||||
"quit": "Exit REPL",
|
||||
}
|
||||
|
||||
while True:
|
||||
try:
|
||||
sess = get_session()
|
||||
# Show URL and working dir in prompt
|
||||
context = sess.working_dir if sess.working_dir != "/" else "/"
|
||||
if sess.current_url:
|
||||
# Truncate long URLs for prompt
|
||||
url_display = sess.current_url[:40] + "..." if len(sess.current_url) > 40 else sess.current_url
|
||||
context = f"{url_display} {context}"
|
||||
|
||||
line = skin.get_input(pt_session, context=context)
|
||||
if not line:
|
||||
continue
|
||||
if line.lower() in ("quit", "exit", "q"):
|
||||
skin.print_goodbye()
|
||||
break
|
||||
if line.lower() == "help":
|
||||
skin.help(_repl_commands)
|
||||
continue
|
||||
|
||||
# Parse and execute command (preserve quoted arguments)
|
||||
try:
|
||||
args = shlex.split(line)
|
||||
except ValueError:
|
||||
args = line.split() # Fallback for unbalanced quotes
|
||||
try:
|
||||
cli.main(args, standalone_mode=False)
|
||||
except SystemExit:
|
||||
pass
|
||||
except click.exceptions.UsageError as e:
|
||||
skin.warning(f"Usage error: {e}")
|
||||
except Exception as e:
|
||||
skin.error(f"{e}")
|
||||
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
skin.print_goodbye()
|
||||
break
|
||||
|
||||
_repl_mode = False
|
||||
|
||||
|
||||
# ── Entry Point ──────────────────────────────────────────────────
|
||||
def main():
|
||||
cli()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,6 @@
|
||||
# Core modules
|
||||
from . import session
|
||||
from . import page
|
||||
from . import fs
|
||||
|
||||
__all__ = ["session", "page", "fs"]
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Filesystem commands for browser automation.
|
||||
|
||||
DOMShell exposes the Accessibility Tree as a virtual filesystem.
|
||||
These commands provide filesystem-like navigation:
|
||||
- ls: List elements at a path
|
||||
- cd: Change working directory
|
||||
- cat: Read element content
|
||||
- grep: Search for text pattern
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cli_anything.browser.core.session import Session
|
||||
|
||||
from cli_anything.browser.utils import domshell_backend as backend
|
||||
|
||||
|
||||
def list_elements(session: "Session", path: str = "") -> dict:
|
||||
"""List elements at a path in the accessibility tree.
|
||||
|
||||
Args:
|
||||
session: Current browser session
|
||||
path: Path to list (empty string uses current working_dir)
|
||||
|
||||
Returns:
|
||||
Dict with list of accessible elements
|
||||
|
||||
Example:
|
||||
>>> list_elements(session, "/main")
|
||||
{"path": "/main", "entries": [{"name": "button", "role": "button", ...}]}
|
||||
"""
|
||||
target_path = path if path else session.working_dir
|
||||
use_daemon = session.daemon_mode
|
||||
return backend.ls(target_path, use_daemon=use_daemon)
|
||||
|
||||
|
||||
def change_directory(session: "Session", path: str) -> dict:
|
||||
"""Change working directory in the accessibility tree.
|
||||
|
||||
Args:
|
||||
session: Current browser session
|
||||
path: New path (can be relative or absolute)
|
||||
|
||||
Returns:
|
||||
Dict with new path confirmation
|
||||
|
||||
Example:
|
||||
>>> change_directory(session, "/main/div[0]")
|
||||
{"path": "/main/div[0]", "working_dir": "/main/div[0]"}
|
||||
"""
|
||||
# Resolve relative paths
|
||||
if path == "..":
|
||||
# Go up one level
|
||||
current = session.working_dir
|
||||
if current == "/":
|
||||
return {"error": "Already at root"}
|
||||
parts = current.rstrip("/").split("/")
|
||||
new_path = "/".join(parts[:-1]) or "/"
|
||||
path = new_path
|
||||
elif path == ".":
|
||||
# Stay in current directory
|
||||
path = session.working_dir
|
||||
elif not path.startswith("/"):
|
||||
# Relative path: append to current working_dir
|
||||
if session.working_dir == "/":
|
||||
path = "/" + path
|
||||
else:
|
||||
path = session.working_dir.rstrip("/") + "/" + path
|
||||
|
||||
use_daemon = session.daemon_mode
|
||||
result = backend.cd(path, use_daemon=use_daemon)
|
||||
# Only update working_dir if backend succeeded
|
||||
if isinstance(result, dict) and "error" not in result:
|
||||
new_working_dir = result.get("path", path)
|
||||
session.set_working_dir(new_working_dir)
|
||||
return result
|
||||
|
||||
|
||||
def read_element(session: "Session", path: str = "") -> dict:
|
||||
"""Read element content from the accessibility tree.
|
||||
|
||||
Args:
|
||||
session: Current browser session
|
||||
path: Path to element (empty string uses current working_dir)
|
||||
|
||||
Returns:
|
||||
Dict with element details
|
||||
|
||||
Example:
|
||||
>>> read_element(session, "/main/button[0]")
|
||||
{"name": "Submit", "role": "button", "text": "Submit", ...}
|
||||
"""
|
||||
target_path = path if path else session.working_dir
|
||||
use_daemon = session.daemon_mode
|
||||
return backend.cat(target_path, use_daemon=use_daemon)
|
||||
|
||||
|
||||
def grep_elements(session: "Session", pattern: str, path: str = "") -> dict:
|
||||
"""Search for pattern in accessibility tree.
|
||||
|
||||
Args:
|
||||
session: Current browser session
|
||||
pattern: Text pattern to search for
|
||||
path: Root path for search (empty string uses current working_dir)
|
||||
|
||||
Returns:
|
||||
Dict with matching elements
|
||||
|
||||
Example:
|
||||
>>> grep_elements(session, "Login")
|
||||
{"matches": ["/main/button[0]", "/main/link[1]"]}
|
||||
"""
|
||||
target_path = path if path else session.working_dir
|
||||
use_daemon = session.daemon_mode
|
||||
return backend.grep(pattern, target_path, use_daemon=use_daemon)
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Page-level commands for browser automation.
|
||||
|
||||
Handles navigation and page operations:
|
||||
- open: Navigate to a URL
|
||||
- reload: Reload the current page
|
||||
- back: Navigate back in history
|
||||
- forward: Navigate forward in history
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cli_anything.browser.core.session import Session
|
||||
|
||||
from cli_anything.browser.utils import domshell_backend as backend
|
||||
|
||||
|
||||
def open_page(session: "Session", url: str) -> dict:
|
||||
"""Open a URL in Chrome.
|
||||
|
||||
Args:
|
||||
session: Current browser session
|
||||
url: URL to navigate to
|
||||
|
||||
Returns:
|
||||
Result dict with URL and status
|
||||
|
||||
Example:
|
||||
>>> open_page(session, "https://example.com")
|
||||
{"url": "https://example.com", "status": "loaded"}
|
||||
"""
|
||||
use_daemon = session.daemon_mode
|
||||
result = backend.open_url(url, use_daemon=use_daemon)
|
||||
session.set_url(url)
|
||||
session.set_working_dir("/") # Reset to root on new page
|
||||
return result
|
||||
|
||||
|
||||
def reload_page(session: "Session") -> dict:
|
||||
"""Reload the current page.
|
||||
|
||||
Args:
|
||||
session: Current browser session
|
||||
|
||||
Returns:
|
||||
Result dict with reload status
|
||||
|
||||
Example:
|
||||
>>> reload_page(session)
|
||||
{"status": "reloaded", "url": "https://example.com"}
|
||||
"""
|
||||
use_daemon = session.daemon_mode
|
||||
result = backend.reload(use_daemon=use_daemon)
|
||||
return result
|
||||
|
||||
|
||||
def go_back(session: "Session") -> dict:
|
||||
"""Navigate back in history.
|
||||
|
||||
Args:
|
||||
session: Current browser session
|
||||
|
||||
Returns:
|
||||
Result dict with previous URL, or error if no history
|
||||
|
||||
Example:
|
||||
>>> go_back(session)
|
||||
{"url": "https://previous.com", "status": "navigated"}
|
||||
"""
|
||||
use_daemon = session.daemon_mode
|
||||
result = backend.back(use_daemon=use_daemon)
|
||||
|
||||
# Update session state if backend returned a URL
|
||||
if isinstance(result, dict) and "url" in result:
|
||||
session.set_url(result["url"], record_history=False)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def go_forward(session: "Session") -> dict:
|
||||
"""Navigate forward in history.
|
||||
|
||||
Args:
|
||||
session: Current browser session
|
||||
|
||||
Returns:
|
||||
Result dict with next URL, or error if no forward history
|
||||
|
||||
Example:
|
||||
>>> go_forward(session)
|
||||
{"url": "https://next.com", "status": "navigated"}
|
||||
"""
|
||||
use_daemon = session.daemon_mode
|
||||
result = backend.forward(use_daemon=use_daemon)
|
||||
|
||||
# Update session state if backend returned a URL
|
||||
if isinstance(result, dict) and "url" in result:
|
||||
session.set_url(result["url"], record_history=False)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_page_info(session: "Session") -> dict:
|
||||
"""Get information about the current page.
|
||||
|
||||
Args:
|
||||
session: Current browser session
|
||||
|
||||
Returns:
|
||||
Dict with page information
|
||||
"""
|
||||
return {
|
||||
"url": session.current_url or "(no page loaded)",
|
||||
"working_dir": session.working_dir,
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Session management for browser automation.
|
||||
|
||||
Maintains page state across CLI commands:
|
||||
- Current URL
|
||||
- Current working directory (in accessibility tree)
|
||||
- Navigation history for back/forward
|
||||
- Daemon mode status
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class Session:
|
||||
"""Browser automation session state.
|
||||
|
||||
The session tracks the current browser state including:
|
||||
- current_url: The URL of the currently loaded page
|
||||
- working_dir: The current path in the accessibility tree (filesystem view)
|
||||
- history: Stack of URLs for back navigation
|
||||
- forward_stack: Stack of URLs for forward navigation
|
||||
- daemon_mode: Whether persistent daemon connection is active
|
||||
"""
|
||||
|
||||
current_url: str = ""
|
||||
working_dir: str = "/"
|
||||
history: list[str] = field(default_factory=list)
|
||||
forward_stack: list[str] = field(default_factory=list)
|
||||
daemon_mode: bool = False
|
||||
|
||||
def set_url(self, url: str, record_history: bool = True) -> None:
|
||||
"""Set the current URL and update history.
|
||||
|
||||
Args:
|
||||
url: New URL to navigate to
|
||||
record_history: Whether to add to history stack
|
||||
"""
|
||||
if record_history and self.current_url:
|
||||
self.history.append(self.current_url)
|
||||
self.forward_stack.clear() # Clear forward stack on new navigation
|
||||
self.current_url = url
|
||||
|
||||
def go_back(self) -> Optional[str]:
|
||||
"""Navigate back in history.
|
||||
|
||||
Returns:
|
||||
Previous URL if available, None otherwise
|
||||
"""
|
||||
if not self.history:
|
||||
return None
|
||||
previous = self.history.pop()
|
||||
self.forward_stack.append(self.current_url)
|
||||
self.current_url = previous
|
||||
return previous
|
||||
|
||||
def go_forward(self) -> Optional[str]:
|
||||
"""Navigate forward in history.
|
||||
|
||||
Returns:
|
||||
Next URL if available, None otherwise
|
||||
"""
|
||||
if not self.forward_stack:
|
||||
return None
|
||||
next_url = self.forward_stack.pop()
|
||||
self.history.append(self.current_url)
|
||||
self.current_url = next_url
|
||||
return next_url
|
||||
|
||||
def set_working_dir(self, path: str) -> None:
|
||||
"""Set the current working directory in the accessibility tree.
|
||||
|
||||
Args:
|
||||
path: New path (e.g., "/main/div[0]")
|
||||
"""
|
||||
self.working_dir = path
|
||||
|
||||
def enable_daemon(self) -> None:
|
||||
"""Enable daemon mode for persistent MCP connection."""
|
||||
self.daemon_mode = True
|
||||
|
||||
def disable_daemon(self) -> None:
|
||||
"""Disable daemon mode."""
|
||||
self.daemon_mode = False
|
||||
|
||||
def status(self) -> dict:
|
||||
"""Get session status as a dict.
|
||||
|
||||
Returns:
|
||||
Dict with current session state
|
||||
"""
|
||||
return {
|
||||
"current_url": self.current_url or "(no page loaded)",
|
||||
"working_dir": self.working_dir,
|
||||
"history_length": len(self.history),
|
||||
"forward_stack_length": len(self.forward_stack),
|
||||
"daemon_mode": self.daemon_mode,
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
---
|
||||
name: "cli-anything-browser"
|
||||
description: "Browser automation CLI using DOMShell MCP server. Maps Chrome's Accessibility Tree to a virtual filesystem for agent-native navigation."
|
||||
---
|
||||
|
||||
# cli-anything-browser
|
||||
|
||||
A command-line interface for browser automation using [DOMShell](https://github.com/apireno/DOMShell)'s MCP server. Navigate web pages using filesystem commands: `ls`, `cd`, `cat`, `grep`, `click`.
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. **Node.js and npx** (for DOMShell MCP server):
|
||||
```bash
|
||||
# Install Node.js from https://nodejs.org/
|
||||
npx --version
|
||||
```
|
||||
|
||||
2. **Chrome/Chromium** with [DOMShell extension](https://chromewebstore.google.com/detail/domshell-browser-filesy/okcliheamhmijccjknkkplploacoidnp):
|
||||
- Install extension in Chrome
|
||||
- Ensure Chrome is running before using CLI
|
||||
|
||||
3. **Python 3.10+**
|
||||
|
||||
### Install CLI
|
||||
|
||||
```bash
|
||||
cd browser/agent-harness
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
## Command Groups
|
||||
|
||||
### `page` — Page Navigation
|
||||
|
||||
- `page open <url>` — Navigate to URL
|
||||
- `page reload` — Reload current page
|
||||
- `page back` — Navigate back in history
|
||||
- `page forward` — Navigate forward in history
|
||||
- `page info` — Show current page info
|
||||
|
||||
### `fs` — Filesystem Commands (Accessibility Tree)
|
||||
|
||||
- `fs ls [path]` — List elements at path
|
||||
- `fs cd <path>` — Change directory
|
||||
- `fs cat [path]` — Read element content
|
||||
- `fs grep <pattern> [path]` — Search for text pattern
|
||||
- `fs pwd` — Print working directory
|
||||
|
||||
### `act` — Action Commands
|
||||
|
||||
- `act click <path>` — Click an element
|
||||
- `act type <path> <text>` — Type text into input
|
||||
|
||||
### `session` — Session Management
|
||||
|
||||
- `session status` — Show session state
|
||||
- `session daemon-start` — Start persistent daemon mode
|
||||
- `session daemon-stop` — Stop daemon mode
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Navigation
|
||||
|
||||
```bash
|
||||
# Open a page
|
||||
cli-anything-browser page open https://example.com
|
||||
|
||||
# Explore structure
|
||||
cli-anything-browser fs ls /
|
||||
cli-anything-browser fs cd /main
|
||||
cli-anything-browser fs ls
|
||||
|
||||
# Go back to root
|
||||
cli-anything-browser fs cd /
|
||||
```
|
||||
|
||||
### Search and Click
|
||||
|
||||
```bash
|
||||
cli-anything-browser fs grep "Login"
|
||||
cli-anything-browser act click /main/button[0]
|
||||
```
|
||||
|
||||
### Form Fill
|
||||
|
||||
```bash
|
||||
cli-anything-browser act type /main/input[0] "user@example.com"
|
||||
cli-anything-browser act click /main/button[0]
|
||||
```
|
||||
|
||||
### JSON Output
|
||||
|
||||
```bash
|
||||
cli-anything-browser --json fs ls /
|
||||
```
|
||||
|
||||
### Daemon Mode (Faster Interactive Use)
|
||||
|
||||
```bash
|
||||
# Start persistent connection
|
||||
cli-anything-browser session daemon-start
|
||||
|
||||
# Run commands (uses persistent connection)
|
||||
cli-anything-browser fs ls /
|
||||
cli-anything-browser fs cd /main
|
||||
|
||||
# Stop daemon when done
|
||||
cli-anything-browser session daemon-stop
|
||||
```
|
||||
|
||||
### Interactive REPL
|
||||
|
||||
```bash
|
||||
cli-anything-browser
|
||||
```
|
||||
|
||||
## Path Syntax
|
||||
|
||||
DOMShell uses a filesystem-like path for the Accessibility Tree:
|
||||
|
||||
```
|
||||
/ — Root (document)
|
||||
/main — Main landmark
|
||||
/main/div[0] — First div in main
|
||||
/main/div[0]/button[2] — Third button in first div
|
||||
```
|
||||
|
||||
- Array indices are **0-based**: `button[0]` is the first button
|
||||
- Use `..` to go up one level
|
||||
- Use `/` for root
|
||||
|
||||
## Agent-Specific Guidance
|
||||
|
||||
### JSON Output for Parsing
|
||||
|
||||
All commands support `--json` flag for machine-readable output:
|
||||
|
||||
```bash
|
||||
cli-anything-browser --json fs ls /
|
||||
```
|
||||
|
||||
Returns:
|
||||
```json
|
||||
{
|
||||
"path": "/",
|
||||
"entries": [
|
||||
{"name": "main", "role": "landmark", "path": "/main"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
The CLI provides clear error messages for common issues:
|
||||
|
||||
- **npx not found**: Install Node.js from https://nodejs.org/
|
||||
- **DOMShell not found**: Run `npx @apireno/domshell --version`
|
||||
- **MCP call failed**: Install DOMShell Chrome extension
|
||||
|
||||
Check `is_available()` return value before running commands.
|
||||
|
||||
### Daemon Mode for Efficiency
|
||||
|
||||
For agent workflows with multiple commands, use daemon mode:
|
||||
|
||||
1. Start daemon: `cli-anything-browser session daemon-start`
|
||||
2. Run commands: Each command reuses the MCP connection
|
||||
3. Stop daemon: `cli-anything-browser session daemon-stop`
|
||||
|
||||
This avoids the 1-3 second cold start overhead for each command.
|
||||
|
||||
## Links
|
||||
|
||||
- [DOMShell GitHub](https://github.com/apireno/DOMShell)
|
||||
- [CLI-Anything](https://github.com/HKUDS/CLI-Anything)
|
||||
- [Issue #90](https://github.com/HKUDS/CLI-Anything/issues/90)
|
||||
@@ -0,0 +1 @@
|
||||
# Tests
|
||||
@@ -0,0 +1,384 @@
|
||||
"""Unit tests for cli-anything-browser — Core modules with mocked MCP backend.
|
||||
|
||||
These tests use synthetic data and mock the MCP backend. No Chrome or DOMShell required.
|
||||
|
||||
Usage:
|
||||
python -m pytest cli_anything/browser/tests/test_core.py -v
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from cli_anything.browser.core.session import Session
|
||||
from cli_anything.browser.core import page, fs
|
||||
|
||||
|
||||
# ── Session Tests ────────────────────────────────────────────────
|
||||
|
||||
class TestSession:
|
||||
"""Test Session state management."""
|
||||
|
||||
def test_session_initial_state(self):
|
||||
"""Session starts with empty state."""
|
||||
sess = Session()
|
||||
assert sess.current_url == ""
|
||||
assert sess.working_dir == "/"
|
||||
assert sess.history == []
|
||||
assert sess.forward_stack == []
|
||||
assert not sess.daemon_mode
|
||||
|
||||
def test_set_url(self):
|
||||
"""Setting URL updates state and records history."""
|
||||
sess = Session()
|
||||
sess.set_url("https://example.com")
|
||||
assert sess.current_url == "https://example.com"
|
||||
assert sess.history == []
|
||||
|
||||
def test_set_url_with_history(self):
|
||||
"""Setting URL with history=True records previous URL."""
|
||||
sess = Session()
|
||||
sess.set_url("https://first.com")
|
||||
sess.set_url("https://second.com")
|
||||
assert sess.history == ["https://first.com"]
|
||||
assert sess.current_url == "https://second.com"
|
||||
|
||||
def test_set_url_clears_forward_stack(self):
|
||||
"""Setting URL clears forward stack."""
|
||||
sess = Session()
|
||||
sess.set_url("https://first.com")
|
||||
sess.set_url("https://second.com", record_history=True)
|
||||
sess.go_back()
|
||||
sess.set_url("https://third.com")
|
||||
assert sess.forward_stack == []
|
||||
|
||||
def test_go_back(self):
|
||||
"""Going back pops from history and pushes to forward stack."""
|
||||
sess = Session()
|
||||
sess.set_url("https://first.com")
|
||||
sess.set_url("https://second.com")
|
||||
sess.set_url("https://third.com")
|
||||
|
||||
previous = sess.go_back()
|
||||
assert previous == "https://second.com"
|
||||
assert sess.current_url == "https://second.com"
|
||||
assert sess.history == ["https://first.com"]
|
||||
assert sess.forward_stack == ["https://third.com"]
|
||||
|
||||
def test_go_back_empty_history(self):
|
||||
"""Going back with empty history returns None."""
|
||||
sess = Session()
|
||||
result = sess.go_back()
|
||||
assert result is None
|
||||
|
||||
def test_go_forward(self):
|
||||
"""Going forward pops from forward stack and pushes to history."""
|
||||
sess = Session()
|
||||
sess.set_url("https://first.com")
|
||||
sess.set_url("https://second.com")
|
||||
sess.go_back()
|
||||
sess.go_back() # Now at first.com
|
||||
|
||||
next_url = sess.go_forward()
|
||||
assert next_url == "https://second.com"
|
||||
assert sess.current_url == "https://second.com"
|
||||
assert sess.history == ["https://first.com"]
|
||||
assert sess.forward_stack == []
|
||||
|
||||
def test_go_forward_empty_stack(self):
|
||||
"""Going forward with empty stack returns None."""
|
||||
sess = Session()
|
||||
result = sess.go_forward()
|
||||
assert result is None
|
||||
|
||||
def test_set_working_dir(self):
|
||||
"""Setting working dir updates state."""
|
||||
sess = Session()
|
||||
sess.set_working_dir("/main/div[0]")
|
||||
assert sess.working_dir == "/main/div[0]"
|
||||
|
||||
def test_daemon_mode(self):
|
||||
"""Daemon mode flag can be toggled."""
|
||||
sess = Session()
|
||||
assert not sess.daemon_mode
|
||||
|
||||
sess.enable_daemon()
|
||||
assert sess.daemon_mode
|
||||
|
||||
sess.disable_daemon()
|
||||
assert not sess.daemon_mode
|
||||
|
||||
def test_status(self):
|
||||
"""Status returns current state as dict."""
|
||||
sess = Session()
|
||||
sess.set_url("https://example.com")
|
||||
sess.set_working_dir("/main")
|
||||
|
||||
status = sess.status()
|
||||
assert status["current_url"] == "https://example.com"
|
||||
assert status["working_dir"] == "/main"
|
||||
assert status["history_length"] == 0
|
||||
assert status["forward_stack_length"] == 0
|
||||
assert not status["daemon_mode"]
|
||||
|
||||
|
||||
# ── Page Module Tests ────────────────────────────────────────────
|
||||
|
||||
class TestPageModule:
|
||||
"""Test page command functions."""
|
||||
|
||||
def test_open_page_updates_session(self):
|
||||
"""Opening a page updates session state."""
|
||||
sess = Session()
|
||||
|
||||
with patch("cli_anything.browser.core.page.backend.open_url") as mock_open:
|
||||
mock_open.return_value = {"url": "https://example.com", "status": "loaded"}
|
||||
|
||||
result = page.open_page(sess, "https://example.com")
|
||||
|
||||
assert sess.current_url == "https://example.com"
|
||||
assert sess.working_dir == "/" # Reset on new page
|
||||
|
||||
def test_reload_page(self):
|
||||
"""Reloading page calls backend."""
|
||||
sess = Session()
|
||||
sess.set_url("https://example.com")
|
||||
|
||||
with patch("cli_anything.browser.core.page.backend.reload") as mock_reload:
|
||||
mock_reload.return_value = {"status": "reloaded"}
|
||||
|
||||
result = page.reload_page(sess)
|
||||
assert result["status"] == "reloaded"
|
||||
|
||||
def test_go_back_updates_session(self):
|
||||
"""Going back updates session and calls backend."""
|
||||
sess = Session()
|
||||
sess.set_url("https://first.com")
|
||||
sess.set_url("https://second.com")
|
||||
|
||||
with patch("cli_anything.browser.core.page.backend.back") as mock_back:
|
||||
mock_back.return_value = {"url": "https://first.com", "status": "navigated"}
|
||||
|
||||
result = page.go_back(sess)
|
||||
|
||||
assert sess.current_url == "https://first.com"
|
||||
assert result["url"] == "https://first.com"
|
||||
|
||||
def test_go_back_empty_history(self):
|
||||
"""Going back with empty history returns error."""
|
||||
sess = Session()
|
||||
|
||||
with patch("cli_anything.browser.core.page.backend.back") as mock_back:
|
||||
mock_back.return_value = {"error": "No history"}
|
||||
|
||||
result = page.go_back(sess)
|
||||
assert "error" in result
|
||||
|
||||
def test_go_forward_updates_session(self):
|
||||
"""Going forward updates session and calls backend."""
|
||||
sess = Session()
|
||||
sess.set_url("https://first.com")
|
||||
sess.set_url("https://second.com")
|
||||
sess.go_back() # Now at first.com
|
||||
|
||||
with patch("cli_anything.browser.core.page.backend.forward") as mock_forward:
|
||||
mock_forward.return_value = {"url": "https://second.com", "status": "navigated"}
|
||||
|
||||
result = page.go_forward(sess)
|
||||
|
||||
assert sess.current_url == "https://second.com"
|
||||
|
||||
def test_get_page_info(self):
|
||||
"""Getting page info returns current state."""
|
||||
sess = Session()
|
||||
sess.set_url("https://example.com")
|
||||
sess.set_working_dir("/main")
|
||||
|
||||
result = page.get_page_info(sess)
|
||||
assert result["url"] == "https://example.com"
|
||||
assert result["working_dir"] == "/main"
|
||||
|
||||
|
||||
# ── Filesystem Module Tests ───────────────────────────────────────
|
||||
|
||||
class TestFsModule:
|
||||
"""Test filesystem command functions."""
|
||||
|
||||
def test_list_elements(self):
|
||||
"""Listing elements calls backend with session working_dir."""
|
||||
sess = Session()
|
||||
sess.set_working_dir("/main")
|
||||
|
||||
with patch("cli_anything.browser.core.fs.backend.ls") as mock_ls:
|
||||
mock_ls.return_value = {
|
||||
"path": "/main",
|
||||
"entries": [{"name": "button", "role": "button", "path": "/main/button[0]"}]
|
||||
}
|
||||
|
||||
result = fs.list_elements(sess)
|
||||
|
||||
mock_ls.assert_called_once_with("/main", use_daemon=False)
|
||||
|
||||
def test_list_elements_with_path(self):
|
||||
"""Listing elements with explicit path overrides working_dir."""
|
||||
sess = Session()
|
||||
sess.set_working_dir("/main")
|
||||
|
||||
with patch("cli_anything.browser.core.fs.backend.ls") as mock_ls:
|
||||
mock_ls.return_value = {"path": "/div", "entries": []}
|
||||
|
||||
result = fs.list_elements(sess, "/div")
|
||||
|
||||
mock_ls.assert_called_once_with("/div", use_daemon=False)
|
||||
|
||||
def test_list_elements_empty_path_uses_working_dir(self):
|
||||
"""Listing with empty path uses session working_dir."""
|
||||
sess = Session()
|
||||
sess.set_working_dir("/main")
|
||||
|
||||
with patch("cli_anything.browser.core.fs.backend.ls") as mock_ls:
|
||||
mock_ls.return_value = {"path": "/main", "entries": []}
|
||||
|
||||
result = fs.list_elements(sess, "")
|
||||
|
||||
mock_ls.assert_called_once_with("/main", use_daemon=False)
|
||||
|
||||
def test_change_directory_absolute_path(self):
|
||||
"""Changing to absolute path updates working_dir."""
|
||||
sess = Session()
|
||||
|
||||
with patch("cli_anything.browser.core.fs.backend.cd") as mock_cd:
|
||||
mock_cd.return_value = {"path": "/main", "status": "changed"}
|
||||
|
||||
result = fs.change_directory(sess, "/main")
|
||||
|
||||
assert sess.working_dir == "/main"
|
||||
mock_cd.assert_called_once_with("/main", use_daemon=False)
|
||||
|
||||
def test_change_directory_relative_parent(self):
|
||||
"""Changing to .. goes up one level."""
|
||||
sess = Session()
|
||||
sess.set_working_dir("/main/div[0]")
|
||||
|
||||
with patch("cli_anything.browser.core.fs.backend.cd") as mock_cd:
|
||||
mock_cd.return_value = {"path": "/main", "status": "changed"}
|
||||
|
||||
result = fs.change_directory(sess, "..")
|
||||
|
||||
assert sess.working_dir == "/main"
|
||||
mock_cd.assert_called_once_with("/main", use_daemon=False)
|
||||
|
||||
def test_change_directory_parent_from_root(self):
|
||||
"""Changing to .. from root stays at root."""
|
||||
sess = Session()
|
||||
sess.set_working_dir("/")
|
||||
|
||||
with patch("cli_anything.browser.core.fs.backend.cd") as mock_cd:
|
||||
result = fs.change_directory(sess, "..")
|
||||
|
||||
assert result["error"] == "Already at root"
|
||||
|
||||
def test_change_directory_current(self):
|
||||
"""Changing to . stays in same directory."""
|
||||
sess = Session()
|
||||
sess.set_working_dir("/main")
|
||||
|
||||
with patch("cli_anything.browser.core.fs.backend.cd") as mock_cd:
|
||||
mock_cd.return_value = {"path": "/main", "status": "changed"}
|
||||
|
||||
result = fs.change_directory(sess, ".")
|
||||
|
||||
assert sess.working_dir == "/main"
|
||||
|
||||
def test_change_directory_relative_path(self):
|
||||
"""Changing to relative path appends to working_dir."""
|
||||
sess = Session()
|
||||
sess.set_working_dir("/main")
|
||||
|
||||
with patch("cli_anything.browser.core.fs.backend.cd") as mock_cd:
|
||||
mock_cd.return_value = {"path": "/main/div[0]", "status": "changed"}
|
||||
|
||||
result = fs.change_directory(sess, "div[0]")
|
||||
|
||||
assert sess.working_dir == "/main/div[0]"
|
||||
mock_cd.assert_called_once_with("/main/div[0]", use_daemon=False)
|
||||
|
||||
def test_read_element(self):
|
||||
"""Reading element calls backend."""
|
||||
sess = Session()
|
||||
|
||||
with patch("cli_anything.browser.core.fs.backend.cat") as mock_cat:
|
||||
mock_cat.return_value = {
|
||||
"name": "button",
|
||||
"role": "button",
|
||||
"text": "Click me"
|
||||
}
|
||||
|
||||
result = fs.read_element(sess, "/main/button[0]")
|
||||
|
||||
mock_cat.assert_called_once_with("/main/button[0]", use_daemon=False)
|
||||
|
||||
def test_read_element_empty_path_uses_working_dir(self):
|
||||
"""Reading with empty path uses session working_dir."""
|
||||
sess = Session()
|
||||
sess.set_working_dir("/main")
|
||||
|
||||
with patch("cli_anything.browser.core.fs.backend.cat") as mock_cat:
|
||||
mock_cat.return_value = {"name": "main", "role": "landmark"}
|
||||
|
||||
result = fs.read_element(sess, "")
|
||||
|
||||
mock_cat.assert_called_once_with("/main", use_daemon=False)
|
||||
|
||||
def test_grep_elements(self):
|
||||
"""Grepping calls backend with pattern."""
|
||||
sess = Session()
|
||||
|
||||
with patch("cli_anything.browser.core.fs.backend.grep") as mock_grep:
|
||||
mock_grep.return_value = {
|
||||
"matches": ["/main/button[0]", "/main/link[1]"]
|
||||
}
|
||||
|
||||
result = fs.grep_elements(sess, "Login")
|
||||
|
||||
mock_grep.assert_called_once_with("Login", "/", use_daemon=False)
|
||||
|
||||
def test_grep_elements_with_path(self):
|
||||
"""Grepping with path uses that path."""
|
||||
sess = Session()
|
||||
|
||||
with patch("cli_anything.browser.core.fs.backend.grep") as mock_grep:
|
||||
mock_grep.return_value = {"matches": ["/main/button[0]"]}
|
||||
|
||||
result = fs.grep_elements(sess, "Login", "/main")
|
||||
|
||||
mock_grep.assert_called_once_with("Login", "/main", use_daemon=False)
|
||||
|
||||
|
||||
# ── Daemon Mode Tests ────────────────────────────────────────────
|
||||
|
||||
class TestDaemonMode:
|
||||
"""Test daemon mode state propagation."""
|
||||
|
||||
def test_daemon_mode_propagates_to_backend(self):
|
||||
"""Commands use daemon mode when session.daemon_mode is True."""
|
||||
sess = Session()
|
||||
sess.enable_daemon()
|
||||
|
||||
with patch("cli_anything.browser.core.fs.backend.ls") as mock_ls:
|
||||
mock_ls.return_value = {"path": "/", "entries": []}
|
||||
|
||||
result = fs.list_elements(sess)
|
||||
|
||||
mock_ls.assert_called_once_with("/", use_daemon=True)
|
||||
|
||||
def test_normal_mode_does_not_use_daemon(self):
|
||||
"""Commands don't use daemon mode when session.daemon_mode is False."""
|
||||
sess = Session()
|
||||
# daemon_mode defaults to False
|
||||
|
||||
with patch("cli_anything.browser.core.fs.backend.ls") as mock_ls:
|
||||
mock_ls.return_value = {"path": "/", "entries": []}
|
||||
|
||||
result = fs.list_elements(sess)
|
||||
|
||||
mock_ls.assert_called_once_with("/", use_daemon=False)
|
||||
@@ -0,0 +1,146 @@
|
||||
"""E2E tests for cli-anything-browser — Requires Chrome + DOMShell extension.
|
||||
|
||||
These tests interact with real Chrome and DOMShell. Skip if DOMShell is not available.
|
||||
|
||||
Usage:
|
||||
python -m pytest cli_anything/browser/tests/test_full_e2e.py -v
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from cli_anything.browser.utils.domshell_backend import is_available
|
||||
from cli_anything.browser.browser_cli import cli
|
||||
|
||||
# Control whether to run DOMShell E2E tests via an environment variable.
|
||||
# This avoids invoking `npx` (inside is_available) during test collection
|
||||
# in environments that have not explicitly opted in.
|
||||
DOMSHELL_E2E_ENABLED = os.environ.get("DOMSHELL_E2E", "").lower() in {"1", "true", "yes"}
|
||||
|
||||
# Skip all tests if E2E is not enabled or DOMShell is not available.
|
||||
# `is_available()` is only called when DOMSHELL_E2E_ENABLED is true.
|
||||
pytestmark = pytest.mark.skipif(
|
||||
(not DOMSHELL_E2E_ENABLED) or (not is_available()[0]),
|
||||
reason=(
|
||||
"DOMShell E2E tests are disabled or DOMShell MCP server not available. "
|
||||
"Set DOMSHELL_E2E=1 to enable and ensure DOMShell is installed from the Chrome Web Store."
|
||||
),
|
||||
)
|
||||
|
||||
TEST_URL = "https://example.com"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runner():
|
||||
return CliRunner()
|
||||
|
||||
|
||||
class TestDependencyChecks:
|
||||
"""Test that dependency checking works correctly."""
|
||||
|
||||
def test_cli_starts_when_domshell_available(self, runner):
|
||||
"""CLI should start successfully when DOMShell is available."""
|
||||
result = runner.invoke(cli, ["--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "Browser CLI" in result.output
|
||||
|
||||
|
||||
class TestPageCommands:
|
||||
"""Test page navigation commands."""
|
||||
|
||||
def test_page_info_empty(self, runner):
|
||||
"""Page info shows empty state initially."""
|
||||
result = runner.invoke(cli, ["--json", "page", "info"])
|
||||
assert result.exit_code == 0
|
||||
import json
|
||||
data = json.loads(result.output)
|
||||
assert data["url"] == "(no page loaded)"
|
||||
assert data["working_dir"] == "/"
|
||||
|
||||
|
||||
class TestFilesystemCommands:
|
||||
"""Test filesystem commands."""
|
||||
|
||||
def test_fs_pwd(self, runner):
|
||||
"""Print working directory shows root initially."""
|
||||
result = runner.invoke(cli, ["fs", "pwd"])
|
||||
assert result.exit_code == 0
|
||||
assert result.output.strip() == "/"
|
||||
|
||||
|
||||
class TestSessionCommands:
|
||||
"""Test session management commands."""
|
||||
|
||||
def test_session_status(self, runner):
|
||||
"""Session status shows current state."""
|
||||
result = runner.invoke(cli, ["--json", "session", "status"])
|
||||
assert result.exit_code == 0
|
||||
import json
|
||||
data = json.loads(result.output)
|
||||
assert "current_url" in data
|
||||
assert "working_dir" in data
|
||||
|
||||
|
||||
class TestDaemonMode:
|
||||
"""Test daemon mode functionality."""
|
||||
|
||||
@pytest.mark.manual
|
||||
def test_daemon_lifecycle(self, runner):
|
||||
"""Manual test: Start daemon, run commands, stop daemon.
|
||||
|
||||
To run manually:
|
||||
cli-anything-browser session daemon-start
|
||||
cli-anything-browser fs pwd
|
||||
cli-anything-browser session daemon-stop
|
||||
"""
|
||||
pass
|
||||
|
||||
def test_daemon_start_with_json(self, runner):
|
||||
"""Daemon start returns success in JSON."""
|
||||
result = runner.invoke(cli, ["--json", "session", "daemon-start"])
|
||||
# May fail if daemon can't start, but should return JSON
|
||||
assert result.exit_code == 0
|
||||
import json
|
||||
data = json.loads(result.output)
|
||||
assert "daemon" in data or "error" in data
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
"""Test error handling for edge cases."""
|
||||
|
||||
def test_invalid_path_gives_error(self, runner):
|
||||
"""Invalid path should give clear error."""
|
||||
# Note: This test's behavior depends on DOMShell's error handling
|
||||
result = runner.invoke(cli, ["fs", "cd", "/invalid/path/that/does/not/exist"])
|
||||
# Should not crash; exit code may be 0 or 1 depending on error handling
|
||||
assert result.exception is None
|
||||
assert result.exit_code in (0, 1)
|
||||
|
||||
def test_empty_page_info(self, runner):
|
||||
"""Page info with no page loaded shows empty state."""
|
||||
result = runner.invoke(cli, ["page", "info"])
|
||||
assert result.exit_code == 0
|
||||
assert "(no page loaded)" in result.output
|
||||
|
||||
|
||||
class TestJSONOutput:
|
||||
"""Test JSON output formatting."""
|
||||
|
||||
def test_json_output_is_valid(self, runner):
|
||||
"""JSON output should be valid and parseable."""
|
||||
result = runner.invoke(cli, ["--json", "session", "status"])
|
||||
assert result.exit_code == 0
|
||||
import json
|
||||
data = json.loads(result.output)
|
||||
assert isinstance(data, dict)
|
||||
|
||||
|
||||
class TestCleanup:
|
||||
"""Cleanup tests to stop daemon if started."""
|
||||
|
||||
def test_stop_daemon_if_running(self, runner):
|
||||
"""Ensure daemon is stopped after tests."""
|
||||
result = runner.invoke(cli, ["session", "daemon-stop"])
|
||||
# Should succeed whether daemon was running or not
|
||||
assert result.exit_code == 0
|
||||
@@ -0,0 +1,5 @@
|
||||
# Utilities
|
||||
from . import domshell_backend
|
||||
from . import repl_skin
|
||||
|
||||
__all__ = ["domshell_backend", "repl_skin"]
|
||||
@@ -0,0 +1,393 @@
|
||||
"""DOMShell MCP client wrapper — communicates with DOMShell MCP server via stdio.
|
||||
|
||||
DOMShell is a browser automation tool that maps Chrome's Accessibility Tree
|
||||
to a virtual filesystem. This module provides a Python interface to DOMShell's
|
||||
MCP server.
|
||||
|
||||
Installation:
|
||||
1. Install DOMShell Chrome extension from Chrome Web Store
|
||||
2. Ensure npx is available: npm install -g npx
|
||||
|
||||
DOMShell GitHub: https://github.com/apireno/DOMShell
|
||||
Chrome Web Store: https://chromewebstore.google.com/detail/domshell-%E2%80%94-browser-filesy/okcliheamhmijccjknkkplploacoidnp
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
import shutil
|
||||
from typing import Any, Optional
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
|
||||
# DOMShell MCP server command
|
||||
DEFAULT_SERVER_CMD = "npx"
|
||||
DEFAULT_SERVER_ARGS = ["@apireno/domshell"]
|
||||
|
||||
# Daemon mode: persistent MCP connection
|
||||
_daemon_session: Optional[ClientSession] = None
|
||||
_daemon_read: Optional[Any] = None
|
||||
_daemon_write: Optional[Any] = None
|
||||
_daemon_client_context: Optional[Any] = None # Store stdio_client context manager
|
||||
|
||||
|
||||
def _check_npx() -> bool:
|
||||
"""Check if npx is available."""
|
||||
return shutil.which("npx") is not None
|
||||
|
||||
|
||||
def _check_npx_has_domshell() -> bool:
|
||||
"""Check if DOMShell package is available to npx."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["npx", "@apireno/domshell", "--version"],
|
||||
capture_output=True,
|
||||
timeout=10,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
return False
|
||||
|
||||
|
||||
def is_available() -> tuple[bool, str]:
|
||||
"""Check if DOMShell MCP server is available.
|
||||
|
||||
Returns:
|
||||
(available, message): Tuple of availability status and descriptive message.
|
||||
|
||||
Examples:
|
||||
>>> is_available()
|
||||
(True, "DOMShell v1.0.0 is available")
|
||||
>>> is_available()
|
||||
(False, "npx not found. Install Node.js from https://nodejs.org/")
|
||||
"""
|
||||
if not _check_npx():
|
||||
return (
|
||||
False,
|
||||
"npx not found. Install Node.js from https://nodejs.org/ "
|
||||
"Then run: npm install -g npx"
|
||||
)
|
||||
|
||||
if not _check_npx_has_domshell():
|
||||
return (
|
||||
False,
|
||||
"DOMShell not found. Run `npx @apireno/domshell --version` once\n"
|
||||
"Note: The first run may download the package (10-50 MB)."
|
||||
)
|
||||
|
||||
# Try to get version
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["npx", "@apireno/domshell", "--version"],
|
||||
capture_output=True,
|
||||
timeout=10,
|
||||
text=True,
|
||||
)
|
||||
version = result.stdout.strip() or "unknown"
|
||||
return True, f"DOMShell {version} is available"
|
||||
except Exception as e:
|
||||
return False, f"DOMShell check failed: {e}"
|
||||
|
||||
|
||||
async def _call_tool(
|
||||
tool_name: str,
|
||||
arguments: dict,
|
||||
use_daemon: bool = False
|
||||
) -> Any:
|
||||
"""Call a DOMShell MCP tool.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the MCP tool (e.g., "domshell_ls", "domshell_cd")
|
||||
arguments: Arguments to pass to the tool
|
||||
use_daemon: If True, use persistent daemon connection (if available)
|
||||
|
||||
Returns:
|
||||
Tool result as returned by MCP server
|
||||
|
||||
Raises:
|
||||
RuntimeError: If MCP server is not available or tool call fails
|
||||
"""
|
||||
global _daemon_session, _daemon_read, _daemon_write
|
||||
|
||||
if use_daemon and _daemon_session is not None:
|
||||
# Use persistent daemon connection
|
||||
try:
|
||||
result = await _daemon_session.call_tool(tool_name, arguments)
|
||||
return result
|
||||
except Exception as e:
|
||||
# Daemon died, fall back to spawning new server
|
||||
await _stop_daemon()
|
||||
|
||||
# Spawn new MCP server process
|
||||
server_params = StdioServerParameters(
|
||||
command=DEFAULT_SERVER_CMD,
|
||||
args=DEFAULT_SERVER_ARGS
|
||||
)
|
||||
|
||||
try:
|
||||
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
|
||||
except Exception as e:
|
||||
raise RuntimeError(
|
||||
f"DOMShell MCP call failed: {e}\n"
|
||||
f"Ensure Chrome is running with DOMShell extension installed.\n"
|
||||
f"Chrome Web Store: https://chromewebstore.google.com/detail/domshell"
|
||||
) from e
|
||||
|
||||
# NOTE: Known limitation - Daemon mode uses asyncio.run() per tool call (in sync wrappers).
|
||||
# Each asyncio.run() creates a new event loop. Async IO objects created in one loop
|
||||
# (like the daemon session) may have issues when accessed from subsequent calls that
|
||||
# create new loops. This is a documented limitation for v1; future work should use
|
||||
# a single long-lived event loop (e.g., background thread + run_coroutine_threadsafe).
|
||||
async def _start_daemon() -> bool:
|
||||
"""Start persistent daemon mode.
|
||||
|
||||
Returns:
|
||||
True if daemon started successfully
|
||||
|
||||
Raises:
|
||||
RuntimeError: If daemon fails to start
|
||||
"""
|
||||
global _daemon_session, _daemon_read, _daemon_write, _daemon_client_context
|
||||
|
||||
if _daemon_session is not None:
|
||||
return True # Already running
|
||||
|
||||
server_params = StdioServerParameters(
|
||||
command=DEFAULT_SERVER_CMD,
|
||||
args=DEFAULT_SERVER_ARGS
|
||||
)
|
||||
|
||||
try:
|
||||
# Store the context manager so we can properly clean it up later
|
||||
_daemon_client_context = stdio_client(server_params)
|
||||
_daemon_read, _daemon_write = await _daemon_client_context.__aenter__()
|
||||
_daemon_session = ClientSession(_daemon_read, _daemon_write)
|
||||
await _daemon_session.__aenter__()
|
||||
await _daemon_session.initialize()
|
||||
return True
|
||||
except Exception as e:
|
||||
_daemon_session = None
|
||||
_daemon_read = None
|
||||
_daemon_write = None
|
||||
_daemon_client_context = None
|
||||
raise RuntimeError(f"Failed to start DOMShell daemon: {e}") from e
|
||||
|
||||
|
||||
async def _stop_daemon() -> None:
|
||||
"""Stop persistent daemon mode."""
|
||||
global _daemon_session, _daemon_read, _daemon_write, _daemon_client_context
|
||||
|
||||
if _daemon_session is None:
|
||||
return
|
||||
|
||||
try:
|
||||
await _daemon_session.__aexit__(None, None, None)
|
||||
if _daemon_client_context:
|
||||
await _daemon_client_context.__aexit__(None, None, None)
|
||||
except Exception:
|
||||
pass # Ignore cleanup errors
|
||||
finally:
|
||||
_daemon_session = None
|
||||
_daemon_read = None
|
||||
_daemon_write = None
|
||||
_daemon_client_context = None
|
||||
|
||||
|
||||
def daemon_started() -> bool:
|
||||
"""Check if daemon mode is active."""
|
||||
return _daemon_session is not None
|
||||
|
||||
|
||||
# ── Sync wrappers for each DOMShell tool ─────────────────────────────
|
||||
|
||||
def ls(path: str = "/", use_daemon: bool = False) -> dict:
|
||||
"""List directory contents in the accessibility tree.
|
||||
|
||||
Args:
|
||||
path: Path in accessibility tree (e.g., "/", "/main", "/main/div[0]")
|
||||
use_daemon: Use persistent daemon connection if available
|
||||
|
||||
Returns:
|
||||
Dict with 'entries' key containing list of accessible elements
|
||||
|
||||
Example:
|
||||
>>> ls("/")
|
||||
{"path": "/", "entries": [{"name": "main", "role": "landmark", ...}]}
|
||||
"""
|
||||
result = asyncio.run(_call_tool("domshell_ls", {"path": path}, use_daemon))
|
||||
return result
|
||||
|
||||
|
||||
def cd(path: str, use_daemon: bool = False) -> dict:
|
||||
"""Change directory in the accessibility tree.
|
||||
|
||||
Args:
|
||||
path: Target path
|
||||
use_daemon: Use persistent daemon connection if available
|
||||
|
||||
Returns:
|
||||
Dict with 'path' key confirming current location
|
||||
|
||||
Example:
|
||||
>>> cd("/main/div[0]")
|
||||
{"path": "/main/div[0]", "element": {...}}
|
||||
"""
|
||||
result = asyncio.run(_call_tool("domshell_cd", {"path": path}, use_daemon))
|
||||
return result
|
||||
|
||||
|
||||
def cat(path: str, use_daemon: bool = False) -> dict:
|
||||
"""Read element content from the accessibility tree.
|
||||
|
||||
Args:
|
||||
path: Path to element
|
||||
use_daemon: Use persistent daemon connection if available
|
||||
|
||||
Returns:
|
||||
Dict with element details including text, role, attributes
|
||||
|
||||
Example:
|
||||
>>> cat("/main/button[0]")
|
||||
{"name": "Submit", "role": "button", "text": "Submit", ...}
|
||||
"""
|
||||
result = asyncio.run(_call_tool("domshell_cat", {"path": path}, use_daemon))
|
||||
return result
|
||||
|
||||
|
||||
def grep(pattern: str, path: str = "/", use_daemon: bool = False) -> dict:
|
||||
"""Search for pattern in accessibility tree.
|
||||
|
||||
Args:
|
||||
pattern: Text pattern to search for
|
||||
path: Root path for search (default: "/")
|
||||
use_daemon: Use persistent daemon connection if available
|
||||
|
||||
Returns:
|
||||
Dict with 'matches' key containing list of matching elements
|
||||
|
||||
Example:
|
||||
>>> grep("Login")
|
||||
{"matches": ["/main/button[0]", "/main/link[1]"]}
|
||||
"""
|
||||
result = asyncio.run(_call_tool(
|
||||
"domshell_grep",
|
||||
{"pattern": pattern, "path": path},
|
||||
use_daemon
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
def click(path: str, use_daemon: bool = False) -> dict:
|
||||
"""Click an element in the accessibility tree.
|
||||
|
||||
Args:
|
||||
path: Path to element to click
|
||||
use_daemon: Use persistent daemon connection if available
|
||||
|
||||
Returns:
|
||||
Dict with action result
|
||||
|
||||
Example:
|
||||
>>> click("/main/button[0]")
|
||||
{"action": "click", "path": "/main/button[0]", "status": "success"}
|
||||
"""
|
||||
result = asyncio.run(_call_tool("domshell_click", {"path": path}, use_daemon))
|
||||
return result
|
||||
|
||||
|
||||
def open_url(url: str, use_daemon: bool = False) -> dict:
|
||||
"""Navigate to a URL in Chrome.
|
||||
|
||||
Args:
|
||||
url: URL to navigate to
|
||||
use_daemon: Use persistent daemon connection if available
|
||||
|
||||
Returns:
|
||||
Dict with navigation result
|
||||
|
||||
Example:
|
||||
>>> open_url("https://example.com")
|
||||
{"url": "https://example.com", "status": "loaded"}
|
||||
"""
|
||||
result = asyncio.run(_call_tool("domshell_open", {"url": url}, use_daemon))
|
||||
return result
|
||||
|
||||
|
||||
def reload(use_daemon: bool = False) -> dict:
|
||||
"""Reload the current page.
|
||||
|
||||
Args:
|
||||
use_daemon: Use persistent daemon connection if available
|
||||
|
||||
Returns:
|
||||
Dict with reload result
|
||||
"""
|
||||
result = asyncio.run(_call_tool("domshell_reload", {}, use_daemon))
|
||||
return result
|
||||
|
||||
|
||||
def back(use_daemon: bool = False) -> dict:
|
||||
"""Navigate back in history.
|
||||
|
||||
Args:
|
||||
use_daemon: Use persistent daemon connection if available
|
||||
|
||||
Returns:
|
||||
Dict with navigation result
|
||||
"""
|
||||
result = asyncio.run(_call_tool("domshell_back", {}, use_daemon))
|
||||
return result
|
||||
|
||||
|
||||
def forward(use_daemon: bool = False) -> dict:
|
||||
"""Navigate forward in history.
|
||||
|
||||
Args:
|
||||
use_daemon: Use persistent daemon connection if available
|
||||
|
||||
Returns:
|
||||
Dict with navigation result
|
||||
"""
|
||||
result = asyncio.run(_call_tool("domshell_forward", {}, use_daemon))
|
||||
return result
|
||||
|
||||
|
||||
def type_text(path: str, text: str, use_daemon: bool = False) -> dict:
|
||||
"""Type text into an input element.
|
||||
|
||||
Args:
|
||||
path: Path to input element
|
||||
text: Text to type
|
||||
use_daemon: Use persistent daemon connection if available
|
||||
|
||||
Returns:
|
||||
Dict with action result
|
||||
"""
|
||||
result = asyncio.run(_call_tool(
|
||||
"domshell_type",
|
||||
{"path": path, "text": text},
|
||||
use_daemon
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
# ── Daemon control functions ───────────────────────────────────────────
|
||||
|
||||
def start_daemon() -> bool:
|
||||
"""Start persistent daemon mode (sync wrapper).
|
||||
|
||||
Returns:
|
||||
True if daemon started successfully
|
||||
|
||||
Raises:
|
||||
RuntimeError: If daemon fails to start
|
||||
"""
|
||||
return asyncio.run(_start_daemon())
|
||||
|
||||
|
||||
def stop_daemon() -> None:
|
||||
"""Stop persistent daemon mode (sync wrapper)."""
|
||||
asyncio.run(_stop_daemon())
|
||||
@@ -0,0 +1,502 @@
|
||||
"""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("browser", version="1.0.0")
|
||||
skin.print_banner()
|
||||
prompt_text = skin.prompt(project_name="https://example.com", modified=False)
|
||||
skin.success("Page loaded")
|
||||
skin.error("Connection failed")
|
||||
skin.warning("DOMShell not found")
|
||||
skin.info("Navigating...")
|
||||
skin.status("URL", "https://example.com")
|
||||
skin.table(headers, rows)
|
||||
skin.print_goodbye()
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# ── ANSI color codes (no external deps for core styling) ──────────────
|
||||
|
||||
_RESET = "\033[0m"
|
||||
_BOLD = "\033[1m"
|
||||
_DIM = "\033[2m"
|
||||
_ITALIC = "\033[3m"
|
||||
_UNDERLINE = "\033[4m"
|
||||
|
||||
# Brand colors
|
||||
_CYAN = "\033[38;5;80m" # cli-anything brand cyan
|
||||
_CYAN_BG = "\033[48;5;80m"
|
||||
_WHITE = "\033[97m"
|
||||
_GRAY = "\033[38;5;245m"
|
||||
_DARK_GRAY = "\033[38;5;240m"
|
||||
_LIGHT_GRAY = "\033[38;5;250m"
|
||||
|
||||
# Software accent colors — each software gets a unique accent
|
||||
_ACCENT_COLORS = {
|
||||
"gimp": "\033[38;5;214m", # warm orange
|
||||
"blender": "\033[38;5;208m", # deep orange
|
||||
"inkscape": "\033[38;5;39m", # bright blue
|
||||
"audacity": "\033[38;5;33m", # navy blue
|
||||
"libreoffice": "\033[38;5;40m", # green
|
||||
"obs_studio": "\033[38;5;55m", # purple
|
||||
"kdenlive": "\033[38;5;69m", # slate blue
|
||||
"shotcut": "\033[38;5;35m", # teal green
|
||||
"ollama": "\033[38;5;255m", # white (Ollama branding)
|
||||
"browser": "\033[38;5;141m", # lavender (browser harness)
|
||||
}
|
||||
_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue
|
||||
|
||||
# Status colors
|
||||
_GREEN = "\033[38;5;78m"
|
||||
_YELLOW = "\033[38;5;220m"
|
||||
_RED = "\033[38;5;196m"
|
||||
_BLUE = "\033[38;5;75m"
|
||||
_MAGENTA = "\033[38;5;176m"
|
||||
|
||||
# ── Brand icon ────────────────────────────────────────────────────────
|
||||
|
||||
# The cli-anything icon: a small colored diamond/chevron mark
|
||||
_ICON = f"{_CYAN}{_BOLD}◆{_RESET}"
|
||||
_ICON_SMALL = f"{_CYAN}▸{_RESET}"
|
||||
|
||||
# ── Box drawing characters ────────────────────────────────────────────
|
||||
|
||||
_H_LINE = "─"
|
||||
_V_LINE = "│"
|
||||
_TL = "╭"
|
||||
_TR = "╮"
|
||||
_BL = "╰"
|
||||
_BR = "╯"
|
||||
_T_DOWN = "┬"
|
||||
_T_UP = "┴"
|
||||
_T_RIGHT = "├"
|
||||
_T_LEFT = "┤"
|
||||
_CROSS = "┼"
|
||||
|
||||
|
||||
def _strip_ansi(text: str) -> str:
|
||||
"""Remove ANSI escape codes for length calculation."""
|
||||
import re
|
||||
return re.sub(r"\033\[[^m]*m", "", text)
|
||||
|
||||
|
||||
def _visible_len(text: str) -> int:
|
||||
"""Get visible length of text (excluding ANSI codes)."""
|
||||
return len(_strip_ansi(text))
|
||||
|
||||
|
||||
class ReplSkin:
|
||||
"""Unified REPL skin for cli-anything CLIs.
|
||||
|
||||
Provides consistent branding, prompts, and message formatting
|
||||
across all CLI harnesses built with the cli-anything methodology.
|
||||
"""
|
||||
|
||||
def __init__(self, software: str, version: str = "1.0.0",
|
||||
history_file: str | None = None):
|
||||
"""Initialize the REPL skin.
|
||||
|
||||
Args:
|
||||
software: Software name (e.g., "gimp", "shotcut", "browser").
|
||||
version: CLI version string.
|
||||
history_file: Path for persistent command history.
|
||||
Defaults to ~/.cli-anything-<software>/history
|
||||
"""
|
||||
self.software = software.lower().replace("-", "_")
|
||||
self.display_name = software.replace("_", " ").title()
|
||||
self.version = version
|
||||
self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT)
|
||||
|
||||
# History file
|
||||
if history_file is None:
|
||||
from pathlib import Path
|
||||
hist_dir = Path.home() / f".cli-anything-{self.software}"
|
||||
hist_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.history_file = str(hist_dir / "history")
|
||||
else:
|
||||
self.history_file = history_file
|
||||
|
||||
# Detect terminal capabilities
|
||||
self._color = self._detect_color_support()
|
||||
|
||||
def _detect_color_support(self) -> bool:
|
||||
"""Check if terminal supports color."""
|
||||
if os.environ.get("NO_COLOR"):
|
||||
return False
|
||||
if os.environ.get("CLI_ANYTHING_NO_COLOR"):
|
||||
return False
|
||||
if not hasattr(sys.stdout, "isatty"):
|
||||
return False
|
||||
return sys.stdout.isatty()
|
||||
|
||||
def _c(self, code: str, text: str) -> str:
|
||||
"""Apply color code if colors are supported."""
|
||||
if not self._color:
|
||||
return text
|
||||
return f"{code}{text}{_RESET}"
|
||||
|
||||
# ── Banner ────────────────────────────────────────────────────────
|
||||
|
||||
def print_banner(self):
|
||||
"""Print the startup banner with branding."""
|
||||
inner = 54
|
||||
|
||||
def _box_line(content: str) -> str:
|
||||
"""Wrap content in box drawing, padding to inner width."""
|
||||
pad = inner - _visible_len(content)
|
||||
vl = self._c(_DARK_GRAY, _V_LINE)
|
||||
return f"{vl}{content}{' ' * max(0, pad)}{vl}"
|
||||
|
||||
top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}")
|
||||
bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}")
|
||||
|
||||
# Title: ◆ cli-anything · Browser
|
||||
icon = self._c(_CYAN + _BOLD, "◆")
|
||||
brand = self._c(_CYAN + _BOLD, "cli-anything")
|
||||
dot = self._c(_DARK_GRAY, "·")
|
||||
name = self._c(self.accent + _BOLD, self.display_name)
|
||||
title = f" {icon} {brand} {dot} {name}"
|
||||
|
||||
ver = f" {self._c(_DARK_GRAY, f' v{self.version}')}"
|
||||
tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}"
|
||||
empty = ""
|
||||
|
||||
print(top)
|
||||
print(_box_line(title))
|
||||
print(_box_line(ver))
|
||||
print(_box_line(empty))
|
||||
print(_box_line(tip))
|
||||
print(bot)
|
||||
print()
|
||||
|
||||
# ── Prompt ────────────────────────────────────────────────────────
|
||||
|
||||
def prompt(self, project_name: str = "", modified: bool = False,
|
||||
context: str = "") -> str:
|
||||
"""Build a styled prompt string for prompt_toolkit or input().
|
||||
|
||||
Args:
|
||||
project_name: Current project name (empty if none open).
|
||||
modified: Whether the project has unsaved changes.
|
||||
context: Optional extra context to show in prompt.
|
||||
|
||||
Returns:
|
||||
Formatted prompt string.
|
||||
"""
|
||||
parts = []
|
||||
|
||||
# Icon
|
||||
if self._color:
|
||||
parts.append(f"{_CYAN}◆{_RESET} ")
|
||||
else:
|
||||
parts.append("> ")
|
||||
|
||||
# Software name
|
||||
parts.append(self._c(self.accent + _BOLD, self.software))
|
||||
|
||||
# Project context
|
||||
if project_name or context:
|
||||
ctx = context or project_name
|
||||
mod = "*" if modified else ""
|
||||
parts.append(f" {self._c(_DARK_GRAY, '[')}")
|
||||
parts.append(self._c(_LIGHT_GRAY, f"{ctx}{mod}"))
|
||||
parts.append(self._c(_DARK_GRAY, ']'))
|
||||
|
||||
parts.append(self._c(_GRAY, " ❯ "))
|
||||
|
||||
return "".join(parts)
|
||||
|
||||
def prompt_tokens(self, project_name: str = "", modified: bool = False,
|
||||
context: str = ""):
|
||||
"""Build prompt_toolkit formatted text tokens for the prompt.
|
||||
|
||||
Use with prompt_toolkit's FormattedText for proper ANSI handling.
|
||||
|
||||
Returns:
|
||||
list of (style, text) tuples for prompt_toolkit.
|
||||
"""
|
||||
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
|
||||
tokens = []
|
||||
|
||||
tokens.append(("class:icon", "◆ "))
|
||||
tokens.append(("class:software", self.software))
|
||||
|
||||
if project_name or context:
|
||||
ctx = context or project_name
|
||||
mod = "*" if modified else ""
|
||||
tokens.append(("class:bracket", " ["))
|
||||
tokens.append(("class:context", f"{ctx}{mod}"))
|
||||
tokens.append(("class:bracket", "]"))
|
||||
|
||||
tokens.append(("class:arrow", " ❯ "))
|
||||
|
||||
return tokens
|
||||
|
||||
def get_prompt_style(self):
|
||||
"""Get a prompt_toolkit Style object matching the skin.
|
||||
|
||||
Returns:
|
||||
prompt_toolkit.styles.Style
|
||||
"""
|
||||
try:
|
||||
from prompt_toolkit.styles import Style
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
|
||||
|
||||
return Style.from_dict({
|
||||
"icon": "#5fdfdf bold", # cyan brand color
|
||||
"software": f"{accent_hex} bold",
|
||||
"bracket": "#585858",
|
||||
"context": "#bcbcbc",
|
||||
"arrow": "#808080",
|
||||
# Completion menu
|
||||
"completion-menu.completion": "bg:#303030 #bcbcbc",
|
||||
"completion-menu.completion.current": f"bg:{accent_hex} #000000",
|
||||
"completion-menu.meta.completion": "bg:#303030 #808080",
|
||||
"completion-menu.meta.completion.current": f"bg:{accent_hex} #000000",
|
||||
# Auto-suggest
|
||||
"auto-suggest": "#585858",
|
||||
# Bottom toolbar
|
||||
"bottom-toolbar": "bg:#1c1c1c #808080",
|
||||
"bottom-toolbar.text": "#808080",
|
||||
})
|
||||
|
||||
# ── Messages ──────────────────────────────────────────────────────
|
||||
|
||||
def success(self, message: str):
|
||||
"""Print a success message with green checkmark."""
|
||||
icon = self._c(_GREEN + _BOLD, "✓")
|
||||
print(f" {icon} {self._c(_GREEN, message)}")
|
||||
|
||||
def error(self, message: str):
|
||||
"""Print an error message with red cross."""
|
||||
icon = self._c(_RED + _BOLD, "✗")
|
||||
print(f" {icon} {self._c(_RED, message)}", file=sys.stderr)
|
||||
|
||||
def warning(self, message: str):
|
||||
"""Print a warning message with yellow triangle."""
|
||||
icon = self._c(_YELLOW + _BOLD, "⚠")
|
||||
print(f" {icon} {self._c(_YELLOW, message)}")
|
||||
|
||||
def info(self, message: str):
|
||||
"""Print an info message with blue dot."""
|
||||
icon = self._c(_BLUE, "●")
|
||||
print(f" {icon} {self._c(_LIGHT_GRAY, message)}")
|
||||
|
||||
def hint(self, message: str):
|
||||
"""Print a subtle hint message."""
|
||||
print(f" {self._c(_DARK_GRAY, message)}")
|
||||
|
||||
def section(self, title: str):
|
||||
"""Print a section header."""
|
||||
print()
|
||||
print(f" {self._c(self.accent + _BOLD, title)}")
|
||||
print(f" {self._c(_DARK_GRAY, _H_LINE * len(title))}")
|
||||
|
||||
# ── Status display ───────────────────────────────────────────────
|
||||
|
||||
def status(self, label: str, value: str):
|
||||
"""Print a key-value status line."""
|
||||
lbl = self._c(_GRAY, f" {label}:")
|
||||
val = self._c(_WHITE, f" {value}")
|
||||
print(f"{lbl}{val}")
|
||||
|
||||
def status_block(self, items: dict[str, str], title: str = ""):
|
||||
"""Print a block of status key-value pairs.
|
||||
|
||||
Args:
|
||||
items: Dict of label -> value pairs.
|
||||
title: Optional title for the block.
|
||||
"""
|
||||
if title:
|
||||
self.section(title)
|
||||
|
||||
max_key = max(len(k) for k in items) if items else 0
|
||||
for label, value in items.items():
|
||||
lbl = self._c(_GRAY, f" {label:<{max_key}}")
|
||||
val = self._c(_WHITE, f" {value}")
|
||||
print(f"{lbl}{val}")
|
||||
|
||||
def progress(self, current: int, total: int, label: str = ""):
|
||||
"""Print a simple progress indicator.
|
||||
|
||||
Args:
|
||||
current: Current step number.
|
||||
total: Total number of steps.
|
||||
label: Optional label for the progress.
|
||||
"""
|
||||
pct = int(current / total * 100) if total > 0 else 0
|
||||
bar_width = 20
|
||||
filled = int(bar_width * current / total) if total > 0 else 0
|
||||
bar = "█" * filled + "░" * (bar_width - filled)
|
||||
text = f" {self._c(_CYAN, bar)} {self._c(_GRAY, f'{pct:3d}%')}"
|
||||
if label:
|
||||
text += f" {self._c(_LIGHT_GRAY, label)}"
|
||||
print(text)
|
||||
|
||||
# ── Table display ─────────────────────────────────────────────────
|
||||
|
||||
def table(self, headers: list[str], rows: list[list[str]],
|
||||
max_col_width: int = 40):
|
||||
"""Print a formatted table with box-drawing characters.
|
||||
|
||||
Args:
|
||||
headers: Column header strings.
|
||||
rows: List of rows, each a list of cell strings.
|
||||
max_col_width: Maximum column width before truncation.
|
||||
"""
|
||||
if not headers:
|
||||
return
|
||||
|
||||
# Calculate column widths
|
||||
col_widths = [min(len(h), max_col_width) for h in headers]
|
||||
for row in rows:
|
||||
for i, cell in enumerate(row):
|
||||
if i < len(col_widths):
|
||||
col_widths[i] = min(
|
||||
max(col_widths[i], len(str(cell))), max_col_width
|
||||
)
|
||||
|
||||
def pad(text: str, width: int) -> str:
|
||||
t = str(text)[:width]
|
||||
return t + " " * (width - len(t))
|
||||
|
||||
# Header
|
||||
header_cells = [
|
||||
self._c(_CYAN + _BOLD, pad(h, col_widths[i]))
|
||||
for i, h in enumerate(headers)
|
||||
]
|
||||
sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
|
||||
header_line = f" {sep.join(header_cells)}"
|
||||
print(header_line)
|
||||
|
||||
# Separator
|
||||
sep_parts = [self._c(_DARK_GRAY, _H_LINE * w) for w in col_widths]
|
||||
sep_line = self._c(_DARK_GRAY, f" {'───'.join([_H_LINE * w for w in col_widths])}")
|
||||
print(sep_line)
|
||||
|
||||
# Rows
|
||||
for row in rows:
|
||||
cells = []
|
||||
for i, cell in enumerate(row):
|
||||
if i < len(col_widths):
|
||||
cells.append(self._c(_LIGHT_GRAY, pad(str(cell), col_widths[i])))
|
||||
row_sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
|
||||
print(f" {row_sep.join(cells)}")
|
||||
|
||||
# ── Help display ──────────────────────────────────────────────────
|
||||
|
||||
def help(self, commands: dict[str, str]):
|
||||
"""Print a formatted help listing.
|
||||
|
||||
Args:
|
||||
commands: Dict of command -> description pairs.
|
||||
"""
|
||||
self.section("Commands")
|
||||
max_cmd = max(len(c) for c in commands) if commands else 0
|
||||
for cmd, desc in commands.items():
|
||||
cmd_styled = self._c(self.accent, f" {cmd:<{max_cmd}}")
|
||||
desc_styled = self._c(_GRAY, f" {desc}")
|
||||
print(f"{cmd_styled}{desc_styled}")
|
||||
print()
|
||||
|
||||
# ── Goodbye ───────────────────────────────────────────────────────
|
||||
|
||||
def print_goodbye(self):
|
||||
"""Print a styled goodbye message."""
|
||||
print(f"\n {_ICON_SMALL} {self._c(_GRAY, 'Goodbye!')}\n")
|
||||
|
||||
# ── Prompt toolkit session factory ────────────────────────────────
|
||||
|
||||
def create_prompt_session(self):
|
||||
"""Create a prompt_toolkit PromptSession with skin styling.
|
||||
|
||||
Returns:
|
||||
A configured PromptSession, or None if prompt_toolkit unavailable.
|
||||
"""
|
||||
try:
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.history import FileHistory
|
||||
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
|
||||
style = self.get_prompt_style()
|
||||
|
||||
session = PromptSession(
|
||||
history=FileHistory(self.history_file),
|
||||
auto_suggest=AutoSuggestFromHistory(),
|
||||
style=style,
|
||||
enable_history_search=True,
|
||||
)
|
||||
return session
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
def get_input(self, pt_session, project_name: str = "",
|
||||
modified: bool = False, context: str = "") -> str:
|
||||
"""Get input from user using prompt_toolkit or fallback.
|
||||
|
||||
Args:
|
||||
pt_session: A prompt_toolkit PromptSession (or None).
|
||||
project_name: Current project name.
|
||||
modified: Whether project has unsaved changes.
|
||||
context: Optional context string.
|
||||
|
||||
Returns:
|
||||
User input string (stripped).
|
||||
"""
|
||||
if pt_session is not None:
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
tokens = self.prompt_tokens(project_name, modified, context)
|
||||
return pt_session.prompt(FormattedText(tokens)).strip()
|
||||
else:
|
||||
raw_prompt = self.prompt(project_name, modified, context)
|
||||
return input(raw_prompt).strip()
|
||||
|
||||
# ── Toolbar builder ───────────────────────────────────────────────
|
||||
|
||||
def bottom_toolbar(self, items: dict[str, str]):
|
||||
"""Create a bottom toolbar callback for prompt_toolkit.
|
||||
|
||||
Args:
|
||||
items: Dict of label -> value pairs to show in toolbar.
|
||||
|
||||
Returns:
|
||||
A callable that returns FormattedText for the toolbar.
|
||||
"""
|
||||
def toolbar():
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
parts = []
|
||||
for i, (k, v) in enumerate(items.items()):
|
||||
if i > 0:
|
||||
parts.append(("class:bottom-toolbar.text", " │ "))
|
||||
parts.append(("class:bottom-toolbar.text", f" {k}: "))
|
||||
parts.append(("class:bottom-toolbar", v))
|
||||
return FormattedText(parts)
|
||||
return toolbar
|
||||
|
||||
|
||||
# ── ANSI 256-color to hex mapping (for prompt_toolkit styles) ─────────
|
||||
|
||||
_ANSI_256_TO_HEX = {
|
||||
"\033[38;5;33m": "#0087ff", # audacity navy blue
|
||||
"\033[38;5;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;141m": "#afafff", # browser lavender
|
||||
"\033[38;5;208m": "#ff8700", # blender deep orange
|
||||
"\033[38;5;214m": "#ffaf00", # gimp warm orange
|
||||
"\033[38;5;255m": "#eeeeee", # ollama white
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
setup.py for cli-anything-browser
|
||||
|
||||
Install with: pip install -e .
|
||||
Or publish to PyPI: python -m build && twine upload dist/*
|
||||
"""
|
||||
|
||||
from setuptools import setup, find_namespace_packages
|
||||
|
||||
with open("cli_anything/browser/README.md", "r", encoding="utf-8") as fh:
|
||||
long_description = fh.read()
|
||||
|
||||
setup(
|
||||
name="cli-anything-browser",
|
||||
version="1.0.0",
|
||||
author="cli-anything contributors",
|
||||
author_email="",
|
||||
description="CLI harness for Browser automation via DOMShell MCP server. Maps Chrome's Accessibility Tree to a virtual filesystem for agent-native browser automation.",
|
||||
long_description=long_description,
|
||||
long_description_content_type="text/markdown",
|
||||
url="https://github.com/HKUDS/CLI-Anything",
|
||||
packages=find_namespace_packages(include=["cli_anything.*"]),
|
||||
classifiers=[
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
"Topic :: Software Development :: Testing",
|
||||
"Topic :: Internet :: WWW/HTTP :: Browsers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
],
|
||||
python_requires=">=3.10",
|
||||
install_requires=[
|
||||
"click>=8.0.0",
|
||||
"prompt-toolkit>=3.0.0",
|
||||
"mcp>=0.1.0",
|
||||
],
|
||||
extras_require={
|
||||
"dev": [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-cov>=4.0.0",
|
||||
],
|
||||
},
|
||||
entry_points={
|
||||
"console_scripts": [
|
||||
"cli-anything-browser=cli_anything.browser.browser_cli:main",
|
||||
],
|
||||
},
|
||||
package_data={
|
||||
"cli_anything.browser": ["skills/*.md"],
|
||||
},
|
||||
include_package_data=True,
|
||||
zip_safe=False,
|
||||
)
|
||||
@@ -424,6 +424,64 @@ the input. Users can't tell anything happened.
|
||||
|
||||
**Priority order for rendering:** native engine → translated filtergraph → script.
|
||||
|
||||
### MCP Backend Pattern
|
||||
|
||||
For services that expose an MCP (Model Context Protocol) server:
|
||||
|
||||
**Use case:** When the software provides an MCP server instead of a traditional CLI.
|
||||
Example: DOMShell provides browser automation via MCP tools.
|
||||
|
||||
**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
|
||||
|
||||
**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
|
||||
|
||||
### Filter Translation Pitfalls
|
||||
|
||||
When translating effects between formats (e.g., MLT → ffmpeg), watch for:
|
||||
@@ -644,6 +702,7 @@ This same SOP applies to any GUI application:
|
||||
| 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
|
||||
|
||||
@@ -27,6 +27,7 @@ Usage:
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import shlex
|
||||
import click
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
@@ -385,7 +386,10 @@ def repl():
|
||||
click.echo(f" {cmd:<12} {subs}")
|
||||
continue
|
||||
|
||||
args = line.split()
|
||||
try:
|
||||
args = shlex.split(line)
|
||||
except ValueError:
|
||||
args = line.split()
|
||||
try:
|
||||
cli.main(args, standalone_mode=False)
|
||||
except SystemExit:
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
---
|
||||
name: >-
|
||||
cli-anything-comfyui
|
||||
description: >-
|
||||
Command-line interface for ComfyUI - AI image generation workflow management via ComfyUI REST API. Designed for AI agents and power users who need to queue workflows, manage models, download generated images, and monitor the generation queue without a GUI.
|
||||
---
|
||||
|
||||
# cli-anything-comfyui
|
||||
|
||||
AI image generation workflow management via the ComfyUI REST API. Designed for AI agents and power users who need to queue workflows, manage models, download generated images, and monitor the generation queue without a GUI.
|
||||
|
||||
## Installation
|
||||
|
||||
This CLI is installed as part of the cli-anything-comfyui package:
|
||||
|
||||
```bash
|
||||
pip install cli-anything-comfyui
|
||||
```
|
||||
|
||||
**Prerequisites:**
|
||||
- Python 3.10+
|
||||
- ComfyUI must be installed and running at http://localhost:8188
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Commands
|
||||
|
||||
```bash
|
||||
# Show help
|
||||
cli-anything-comfyui --help
|
||||
|
||||
# Start interactive REPL mode
|
||||
cli-anything-comfyui repl
|
||||
|
||||
# Check server stats
|
||||
cli-anything-comfyui system stats
|
||||
|
||||
# Run with JSON output (for agent consumption)
|
||||
cli-anything-comfyui --json system stats
|
||||
```
|
||||
|
||||
### REPL Mode
|
||||
|
||||
Start an interactive session for exploratory use:
|
||||
|
||||
```bash
|
||||
cli-anything-comfyui repl
|
||||
# Enter commands interactively with tab-completion and history
|
||||
```
|
||||
|
||||
## Command Groups
|
||||
|
||||
### Workflow
|
||||
|
||||
Workflow management commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `list` | List saved workflows |
|
||||
| `load` | Load a workflow from a JSON file |
|
||||
| `validate` | Validate a workflow JSON against the ComfyUI node graph |
|
||||
|
||||
### Queue
|
||||
|
||||
Generation queue management.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `prompt` | Queue a workflow for execution |
|
||||
| `status` | Show current queue status (running and pending) |
|
||||
| `clear` | Clear the generation queue |
|
||||
| `history` | Show prompt execution history |
|
||||
| `interrupt` | Interrupt the currently running generation |
|
||||
|
||||
### Models
|
||||
|
||||
Model discovery commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `checkpoints` | List available checkpoint models |
|
||||
| `loras` | List available LoRA models |
|
||||
| `vaes` | List available VAE models |
|
||||
| `controlnets` | List available ControlNet models |
|
||||
| `node-info` | Show detailed info for a specific node type |
|
||||
| `list-nodes` | List all available node types |
|
||||
|
||||
### Images
|
||||
|
||||
Generated image management.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `list` | List generated images on the server |
|
||||
| `download` | Download a specific generated image |
|
||||
| `download-all` | Download all images from a prompt execution |
|
||||
|
||||
### System
|
||||
|
||||
Server status and information.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `stats` | Show ComfyUI system statistics (GPU, CPU, memory) |
|
||||
| `info` | Show ComfyUI server info and extensions |
|
||||
|
||||
## Examples
|
||||
|
||||
### Check System Status
|
||||
|
||||
```bash
|
||||
# Server stats
|
||||
cli-anything-comfyui system stats
|
||||
|
||||
# Server info
|
||||
cli-anything-comfyui system info
|
||||
```
|
||||
|
||||
### Discover Available Models
|
||||
|
||||
```bash
|
||||
# List checkpoints
|
||||
cli-anything-comfyui models checkpoints
|
||||
|
||||
# List LoRAs
|
||||
cli-anything-comfyui models loras
|
||||
|
||||
# List all node types
|
||||
cli-anything-comfyui models list-nodes
|
||||
```
|
||||
|
||||
### Queue and Monitor Generation
|
||||
|
||||
```bash
|
||||
# Queue a workflow
|
||||
cli-anything-comfyui queue prompt --workflow my_workflow.json
|
||||
|
||||
# Check queue status
|
||||
cli-anything-comfyui queue status
|
||||
|
||||
# View execution history
|
||||
cli-anything-comfyui --json queue history
|
||||
```
|
||||
|
||||
### Download Generated Images
|
||||
|
||||
```bash
|
||||
# List generated images
|
||||
cli-anything-comfyui images list
|
||||
|
||||
# Download a specific image
|
||||
cli-anything-comfyui images download --filename ComfyUI_00001_.png --output ./out.png
|
||||
|
||||
# Download all images from a prompt
|
||||
cli-anything-comfyui images download-all --prompt-id <id> --output-dir ./outputs
|
||||
```
|
||||
|
||||
## 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-comfyui system stats
|
||||
|
||||
# JSON output for agents
|
||||
cli-anything-comfyui --json system stats
|
||||
```
|
||||
|
||||
## 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 ComfyUI is running** with `system stats` before other commands
|
||||
|
||||
## More Information
|
||||
|
||||
- Full documentation: See README.md in the package
|
||||
- Test coverage: See TEST.md in the package
|
||||
- Methodology: See HARNESS.md in the cli-anything-plugin
|
||||
|
||||
## Version
|
||||
|
||||
1.0.0
|
||||
@@ -1,51 +1,54 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
setup.py for cli-anything-comfyui
|
||||
|
||||
Install with: pip install -e .
|
||||
Or publish to PyPI: python -m build && twine upload dist/*
|
||||
"""
|
||||
|
||||
from setuptools import setup, find_namespace_packages
|
||||
|
||||
setup(
|
||||
name="cli-anything-comfyui",
|
||||
version="1.0.0",
|
||||
author="cli-anything contributors",
|
||||
author_email="",
|
||||
description="CLI harness for ComfyUI - AI image generation workflow management via ComfyUI REST API. Requires: ComfyUI running at http://localhost:8188",
|
||||
long_description=open("cli_anything/comfyui/README.md", "r", encoding="utf-8").read()
|
||||
if __import__("os").path.exists("cli_anything/comfyui/README.md")
|
||||
else "CLI harness for ComfyUI AI image generation.",
|
||||
long_description_content_type="text/markdown",
|
||||
url="https://github.com/HKUDS/CLI-Anything",
|
||||
packages=find_namespace_packages(include=["cli_anything.*"]),
|
||||
classifiers=[
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"Topic :: Multimedia :: Graphics",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
],
|
||||
python_requires=">=3.10",
|
||||
install_requires=[
|
||||
"click>=8.0.0",
|
||||
"requests>=2.28.0",
|
||||
],
|
||||
extras_require={
|
||||
"dev": [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-cov>=4.0.0",
|
||||
],
|
||||
},
|
||||
entry_points={
|
||||
"console_scripts": [
|
||||
"cli-anything-comfyui=cli_anything.comfyui.comfyui_cli:main",
|
||||
],
|
||||
},
|
||||
include_package_data=True,
|
||||
zip_safe=False,
|
||||
)
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
setup.py for cli-anything-comfyui
|
||||
|
||||
Install with: pip install -e .
|
||||
Or publish to PyPI: python -m build && twine upload dist/*
|
||||
"""
|
||||
|
||||
from setuptools import setup, find_namespace_packages
|
||||
|
||||
setup(
|
||||
name="cli-anything-comfyui",
|
||||
version="1.0.0",
|
||||
author="cli-anything contributors",
|
||||
author_email="",
|
||||
description="CLI harness for ComfyUI - AI image generation workflow management via ComfyUI REST API. Requires: ComfyUI running at http://localhost:8188",
|
||||
long_description=open("cli_anything/comfyui/README.md", "r", encoding="utf-8").read()
|
||||
if __import__("os").path.exists("cli_anything/comfyui/README.md")
|
||||
else "CLI harness for ComfyUI AI image generation.",
|
||||
long_description_content_type="text/markdown",
|
||||
url="https://github.com/HKUDS/CLI-Anything",
|
||||
packages=find_namespace_packages(include=["cli_anything.*"]),
|
||||
classifiers=[
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"Topic :: Multimedia :: Graphics",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
],
|
||||
python_requires=">=3.10",
|
||||
install_requires=[
|
||||
"click>=8.0.0",
|
||||
"requests>=2.28.0",
|
||||
],
|
||||
extras_require={
|
||||
"dev": [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-cov>=4.0.0",
|
||||
],
|
||||
},
|
||||
entry_points={
|
||||
"console_scripts": [
|
||||
"cli-anything-comfyui=cli_anything.comfyui.comfyui_cli:main",
|
||||
],
|
||||
},
|
||||
package_data={
|
||||
"cli_anything.comfyui": ["skills/*.md"],
|
||||
},
|
||||
include_package_data=True,
|
||||
zip_safe=False,
|
||||
)
|
||||
|
||||
@@ -17,6 +17,7 @@ Usage:
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import shlex
|
||||
import click
|
||||
from typing import Optional
|
||||
|
||||
@@ -1006,7 +1007,10 @@ def repl(project_path):
|
||||
_repl_help(skin)
|
||||
continue
|
||||
|
||||
args = line.split()
|
||||
try:
|
||||
args = shlex.split(line)
|
||||
except ValueError:
|
||||
args = line.split()
|
||||
try:
|
||||
cli.main(args, standalone_mode=False)
|
||||
except SystemExit:
|
||||
|
||||
@@ -17,6 +17,7 @@ Usage:
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import shlex
|
||||
import click
|
||||
from typing import Optional
|
||||
|
||||
@@ -712,7 +713,10 @@ def repl(project_path):
|
||||
skin.help(commands_dict)
|
||||
continue
|
||||
|
||||
args = line.split()
|
||||
try:
|
||||
args = shlex.split(line)
|
||||
except ValueError:
|
||||
args = line.split()
|
||||
try:
|
||||
cli.main(args, standalone_mode=False)
|
||||
except SystemExit:
|
||||
|
||||
@@ -17,6 +17,7 @@ Usage:
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import shlex
|
||||
import click
|
||||
from typing import Optional
|
||||
|
||||
@@ -703,7 +704,10 @@ def repl(project_path):
|
||||
_repl_help(skin)
|
||||
continue
|
||||
|
||||
args = line.split()
|
||||
try:
|
||||
args = shlex.split(line)
|
||||
except ValueError:
|
||||
args = line.split()
|
||||
try:
|
||||
cli.main(args, standalone_mode=False)
|
||||
except SystemExit:
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
---
|
||||
name: >-
|
||||
cli-anything-mermaid
|
||||
description: >-
|
||||
Command-line interface for Mermaid Live Editor - Create, edit, and render Mermaid diagrams via stateful project files and mermaid.ink renderer URLs. Designed for AI agents and power users who need to generate flowcharts, sequence diagrams, and other visualizations without a GUI.
|
||||
---
|
||||
|
||||
# cli-anything-mermaid
|
||||
|
||||
Create, edit, and render Mermaid diagrams via stateful project files and the mermaid.ink renderer. Designed for AI agents and power users who need to generate flowcharts, sequence diagrams, and other visualizations without a GUI.
|
||||
|
||||
## Installation
|
||||
|
||||
This CLI is installed as part of the cli-anything-mermaid package:
|
||||
|
||||
```bash
|
||||
pip install cli-anything-mermaid
|
||||
```
|
||||
|
||||
**Prerequisites:**
|
||||
- Python 3.10+
|
||||
- No external software required (rendering uses mermaid.ink cloud service)
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Commands
|
||||
|
||||
```bash
|
||||
# Show help
|
||||
cli-anything-mermaid --help
|
||||
|
||||
# Start interactive REPL mode
|
||||
cli-anything-mermaid
|
||||
|
||||
# Create a new project
|
||||
cli-anything-mermaid project new -o diagram.json
|
||||
|
||||
# Run with JSON output (for agent consumption)
|
||||
cli-anything-mermaid --json project info
|
||||
```
|
||||
|
||||
### REPL Mode
|
||||
|
||||
When invoked without a subcommand, the CLI enters an interactive REPL session:
|
||||
|
||||
```bash
|
||||
cli-anything-mermaid
|
||||
# Enter commands interactively with tab-completion and history
|
||||
```
|
||||
|
||||
## Command Groups
|
||||
|
||||
### Project
|
||||
|
||||
Project lifecycle commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `new` | Create a new Mermaid project with optional sample preset and theme |
|
||||
| `open` | Open an existing Mermaid project file |
|
||||
| `save` | Save the current project to a file |
|
||||
| `info` | Show current project information |
|
||||
| `samples` | List available sample diagram presets |
|
||||
|
||||
### Diagram
|
||||
|
||||
Diagram source manipulation commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `set` | Replace the Mermaid source text (inline or from file) |
|
||||
| `show` | Print the current Mermaid source code |
|
||||
|
||||
### Export
|
||||
|
||||
Render and share commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `render` | Render the diagram to SVG or PNG via mermaid.ink |
|
||||
| `share` | Generate a Mermaid Live Editor URL for sharing |
|
||||
|
||||
### Session
|
||||
|
||||
Session state commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `status` | Show current session state |
|
||||
| `undo` | Undo the last diagram source change |
|
||||
| `redo` | Redo the last undone change |
|
||||
|
||||
## Examples
|
||||
|
||||
### Create and Render a Flowchart
|
||||
|
||||
```bash
|
||||
# Create a project with flowchart sample
|
||||
cli-anything-mermaid project new --sample flowchart -o flow.json
|
||||
|
||||
# Replace diagram source
|
||||
cli-anything-mermaid --project flow.json diagram set --text "graph TD; A-->B; B-->C;"
|
||||
|
||||
# Render to SVG
|
||||
cli-anything-mermaid --project flow.json export render output.svg --format svg
|
||||
```
|
||||
|
||||
### Create a Sequence Diagram
|
||||
|
||||
```bash
|
||||
# Create project with sequence sample
|
||||
cli-anything-mermaid project new --sample sequence -o seq.json
|
||||
|
||||
# Set diagram from file
|
||||
cli-anything-mermaid --project seq.json diagram set --file my_diagram.mmd
|
||||
|
||||
# Render to PNG
|
||||
cli-anything-mermaid --project seq.json export render output.png --format png
|
||||
```
|
||||
|
||||
### Share a Diagram
|
||||
|
||||
```bash
|
||||
# Generate an editable Mermaid Live URL
|
||||
cli-anything-mermaid --project flow.json export share --mode edit
|
||||
|
||||
# Generate a view-only URL
|
||||
cli-anything-mermaid --project flow.json export share --mode view
|
||||
```
|
||||
|
||||
### Interactive REPL Session
|
||||
|
||||
```bash
|
||||
cli-anything-mermaid
|
||||
# new flowchart
|
||||
# set graph TD; A-->B; B-->C;
|
||||
# render output.svg
|
||||
# share
|
||||
# quit
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
The CLI maintains session state with:
|
||||
|
||||
- **Undo/Redo**: Revert or replay diagram source changes
|
||||
- **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-mermaid project info
|
||||
|
||||
# JSON output for agents
|
||||
cli-anything-mermaid --json project info
|
||||
```
|
||||
|
||||
## 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 render operations
|
||||
|
||||
## More Information
|
||||
|
||||
- Full documentation: See README.md in the package
|
||||
- Test coverage: See TEST.md in the package
|
||||
- Methodology: See HARNESS.md in the cli-anything-plugin
|
||||
|
||||
## Version
|
||||
|
||||
1.0.0
|
||||
@@ -25,5 +25,9 @@ setup(
|
||||
"cli-anything-mermaid=cli_anything.mermaid.mermaid_cli:main",
|
||||
]
|
||||
},
|
||||
package_data={
|
||||
"cli_anything.mermaid": ["skills/*.md"],
|
||||
},
|
||||
include_package_data=True,
|
||||
python_requires=">=3.10",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# MUSESCORE.md — Software-Specific Analysis and SOP
|
||||
|
||||
## 1. Software Overview
|
||||
|
||||
**MuseScore 4** is a free, open-source music notation editor. It reads and writes `.mscz` (native), `.mxl` (compressed MusicXML), `.mid` (MIDI), and `.musicxml` formats.
|
||||
|
||||
- Homepage: https://musescore.org
|
||||
- Version tested: 4.6.5
|
||||
- License: GPL v3
|
||||
|
||||
## 2. Backend Engine
|
||||
|
||||
The `mscore` binary provides all rendering, transposition, and conversion capabilities.
|
||||
|
||||
### Binary Locations
|
||||
|
||||
| Platform | Path |
|
||||
|----------|------|
|
||||
| macOS | `/Applications/MuseScore 4.app/Contents/MacOS/mscore` |
|
||||
| Linux | `/usr/bin/mscore4` or `/usr/local/bin/mscore4` |
|
||||
| Windows | `C:\Program Files\MuseScore 4\bin\MuseScore4.exe` |
|
||||
|
||||
### Data Model
|
||||
|
||||
- `.mscz` = ZIP archive containing:
|
||||
- `.mscx` XML (score data)
|
||||
- `score_style.mss` (style overrides)
|
||||
- `audiosettings.json`
|
||||
- `viewsettings.json`
|
||||
- `Thumbnails/thumbnail.png`
|
||||
- `.mxl` = ZIP archive containing MusicXML `score.xml`
|
||||
|
||||
## 3. CLI Capabilities
|
||||
|
||||
### Export (`-o`)
|
||||
```bash
|
||||
mscore -o output.pdf input.mscz # PDF
|
||||
mscore -o output.mid input.mscz # MIDI
|
||||
mscore -o output.mp3 --bitrate 192 input.mscz # MP3
|
||||
mscore -o output.png -r 150 input.mscz # PNG (per page)
|
||||
mscore -o output.musicxml input.mscz # MusicXML
|
||||
```
|
||||
|
||||
### Transpose (`--transpose` + `-o`)
|
||||
JSON format:
|
||||
```json
|
||||
{
|
||||
"mode": "to_key|by_interval|diatonically",
|
||||
"direction": "up|down|closest",
|
||||
"targetKey": 0,
|
||||
"transposeInterval": 0,
|
||||
"transposeKeySignatures": true,
|
||||
"transposeChordNames": true,
|
||||
"useDoubleSharpsFlats": false
|
||||
}
|
||||
```
|
||||
|
||||
### Key Signature Integer Mapping
|
||||
```
|
||||
-7=Cb -6=Gb -5=Db -4=Ab -3=Eb -2=Bb -1=F
|
||||
0=C 1=G 2=D 3=A 4=E 5=B 6=F# 7=C#
|
||||
```
|
||||
|
||||
### Metadata (`--score-meta`)
|
||||
Returns JSON: title, composer, keysig, timesig, tempo, duration, measures, pages, parts.
|
||||
|
||||
### Parts (`--score-parts`)
|
||||
Returns JSON with part names and base64-encoded .mscz data per part.
|
||||
|
||||
### Media (`--score-media`)
|
||||
Returns JSON with pngs, svgs, pdf, midi, mxml, metadata.
|
||||
|
||||
### Batch Jobs (`-j`)
|
||||
```json
|
||||
[{"in": "/path/input.mscz", "out": "/path/output.pdf"}]
|
||||
```
|
||||
|
||||
### Exit Codes
|
||||
- `0` — success
|
||||
- `31` — invalid transpose options
|
||||
- `23` — invalid batch job format
|
||||
|
||||
### Output Verification (Magic Bytes)
|
||||
| Format | Magic |
|
||||
|--------|-------|
|
||||
| PDF | `%PDF-` |
|
||||
| MIDI | `MThd` |
|
||||
| MP3 | `0xfffb` or `ID3` |
|
||||
| PNG | `\x89PNG` |
|
||||
| MSCZ | `PK` (ZIP) |
|
||||
|
||||
## 4. GUI-to-CLI Mapping
|
||||
|
||||
| GUI Action | CLI Equivalent |
|
||||
|-----------|---------------|
|
||||
| File → Export → PDF | `mscore -o output.pdf input.mscz` |
|
||||
| Tools → Transpose | `mscore --transpose '{...}' -o out.mscz input.mscz` |
|
||||
| File → Parts | `mscore --score-parts input.mscz` |
|
||||
| File → Score Properties | `mscore --score-meta input.mscz` |
|
||||
|
||||
## 5. CLI Architecture
|
||||
|
||||
### Command Groups (v1 MVP)
|
||||
|
||||
| Group | Purpose | Backend |
|
||||
|-------|---------|---------|
|
||||
| `project` | open, info, save | MusicXML/MSCX parsing |
|
||||
| `transpose` | by-key, by-interval, diatonic | `--transpose` + `-o` |
|
||||
| `parts` | list, extract, generate | `--score-parts` |
|
||||
| `export` | pdf, png, svg, mp3, flac, wav, midi, musicxml, braille, batch | `-o` |
|
||||
| `instruments` | list, add, remove, reorder | MSCX XML manipulation |
|
||||
| `media` | probe, diff, stats | `--score-meta`, `--diff` |
|
||||
| `session` | status, undo, redo, history | In-memory state + JSON persistence |
|
||||
|
||||
### State Model
|
||||
- In-memory `Session` dataclass with undo/redo stacks
|
||||
- `fcntl.flock()` for safe concurrent JSON writes
|
||||
- Session singleton via `get_session()`
|
||||
@@ -0,0 +1,49 @@
|
||||
# cli-anything-musescore
|
||||
|
||||
CLI wrapper for **MuseScore 4** — the first music notation tool in the [CLI-Anything](https://github.com/HKUDS/CLI-Anything) ecosystem.
|
||||
|
||||
## Features
|
||||
|
||||
- **Transpose** scores by key, interval, or diatonically
|
||||
- **Export** to PDF, PNG, SVG, MP3, FLAC, WAV, MIDI, MusicXML, Braille
|
||||
- **Extract parts** from multi-instrument scores
|
||||
- **Manage instruments** — list, add, remove, reorder
|
||||
- **Analyze scores** — metadata, diff, statistics
|
||||
- **Interactive REPL** with undo/redo session management
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python >= 3.10
|
||||
- [MuseScore 4](https://musescore.org/en/download) installed
|
||||
- macOS, Linux, or Windows
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Interactive REPL
|
||||
cli-anything-musescore
|
||||
|
||||
# One-shot commands with JSON output
|
||||
cli-anything-musescore --json project info -i score.mscz
|
||||
cli-anything-musescore --json transpose by-key -i score.mscz -o out.mscz --target-key "C major"
|
||||
cli-anything-musescore --json export pdf -i score.mscz -o score.pdf
|
||||
cli-anything-musescore --json parts list -i score.mscz
|
||||
```
|
||||
|
||||
## Command Groups
|
||||
|
||||
| Group | Commands | Description |
|
||||
|-------|----------|-------------|
|
||||
| `project` | open, info, save | Score file management |
|
||||
| `transpose` | by-key, by-interval, diatonic | Transposition |
|
||||
| `parts` | list, extract, generate | Part extraction |
|
||||
| `export` | pdf, png, svg, mp3, flac, wav, midi, musicxml, braille, batch | Rendering |
|
||||
| `instruments` | list, add, remove, reorder | Instrument management |
|
||||
| `media` | probe, diff, stats | Score analysis |
|
||||
| `session` | status, undo, redo, history | Session management |
|
||||
@@ -0,0 +1,2 @@
|
||||
"""cli-anything-musescore — CLI wrapper for MuseScore 4."""
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Allow running as python3 -m cli_anything.musescore"""
|
||||
from cli_anything.musescore.musescore_cli import main
|
||||
main()
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Export/render pipeline via mscore backend."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from cli_anything.musescore.utils import musescore_backend as backend
|
||||
|
||||
|
||||
# ── Supported export formats ──────────────────────────────────────────
|
||||
|
||||
EXPORT_FORMATS = {
|
||||
"pdf": {"ext": ".pdf", "magic": b"%PDF-", "desc": "PDF document"},
|
||||
"png": {"ext": ".png", "magic": b"\x89PNG", "desc": "PNG image (per page)"},
|
||||
"svg": {"ext": ".svg", "magic": None, "desc": "SVG vector (per page)"},
|
||||
"mp3": {"ext": ".mp3", "magic": None, "desc": "MP3 audio"},
|
||||
"flac": {"ext": ".flac", "magic": b"fLaC", "desc": "FLAC audio"},
|
||||
"wav": {"ext": ".wav", "magic": b"RIFF", "desc": "WAV audio"},
|
||||
"midi": {"ext": ".mid", "magic": b"MThd", "desc": "MIDI file"},
|
||||
"musicxml": {"ext": ".musicxml", "magic": None, "desc": "MusicXML"},
|
||||
"mscz": {"ext": ".mscz", "magic": b"PK", "desc": "MuseScore file"},
|
||||
"braille": {"ext": ".brf", "magic": None, "desc": "Braille music notation"},
|
||||
}
|
||||
|
||||
|
||||
def export_score(input_path: str, output_path: str, *,
|
||||
fmt: str | None = None,
|
||||
dpi: int | None = None,
|
||||
bitrate: int | None = None,
|
||||
trim: int | None = None,
|
||||
style: str | None = None,
|
||||
sound_profile: str | None = None,
|
||||
export_parts: bool = False) -> dict:
|
||||
"""Export a score to the specified format.
|
||||
|
||||
Format is auto-detected from output_path extension, or can be
|
||||
specified explicitly via fmt.
|
||||
|
||||
Returns:
|
||||
Dict with export result info.
|
||||
"""
|
||||
if not os.path.isfile(input_path):
|
||||
raise FileNotFoundError(f"Score file not found: {input_path}")
|
||||
|
||||
# Determine format
|
||||
if fmt is None:
|
||||
ext = Path(output_path).suffix.lower()
|
||||
fmt = _ext_to_format(ext)
|
||||
if fmt not in EXPORT_FORMATS:
|
||||
raise ValueError(f"Unsupported format: {fmt}. Supported: {list(EXPORT_FORMATS.keys())}")
|
||||
|
||||
# Ensure output directory exists
|
||||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Run export
|
||||
result_path = backend.export_score(
|
||||
input_path, output_path,
|
||||
dpi=dpi, bitrate=bitrate, trim=trim,
|
||||
style=style, sound_profile=sound_profile,
|
||||
export_parts=export_parts,
|
||||
)
|
||||
|
||||
return {
|
||||
"input": input_path,
|
||||
"output": str(result_path),
|
||||
"format": fmt,
|
||||
}
|
||||
|
||||
|
||||
def batch_export(input_path: str, outputs: list[str]) -> list[dict]:
|
||||
"""Export a score to multiple formats at once via batch job.
|
||||
|
||||
Args:
|
||||
input_path: Path to input score.
|
||||
outputs: List of output file paths.
|
||||
|
||||
Returns:
|
||||
List of dicts with per-output results.
|
||||
"""
|
||||
if not os.path.isfile(input_path):
|
||||
raise FileNotFoundError(f"Score file not found: {input_path}")
|
||||
|
||||
job_list = [{"in": str(input_path), "out": str(o)} for o in outputs]
|
||||
result_paths = backend.batch_convert(job_list)
|
||||
|
||||
return [
|
||||
{
|
||||
"input": input_path,
|
||||
"output": str(p),
|
||||
"format": _ext_to_format(p.suffix),
|
||||
}
|
||||
for p in result_paths
|
||||
]
|
||||
|
||||
|
||||
def verify_output(path: str, expected_format: str | None = None) -> dict:
|
||||
"""Verify an exported file using magic bytes.
|
||||
|
||||
Args:
|
||||
path: Path to the output file.
|
||||
expected_format: Expected format name (e.g., "pdf", "midi").
|
||||
|
||||
Returns:
|
||||
Dict with verification results.
|
||||
"""
|
||||
if not os.path.isfile(path):
|
||||
return {"path": path, "exists": False, "valid": False}
|
||||
|
||||
size = os.path.getsize(path)
|
||||
if size == 0:
|
||||
return {"path": path, "exists": True, "size": 0, "valid": False}
|
||||
|
||||
result = {
|
||||
"path": path,
|
||||
"exists": True,
|
||||
"size": size,
|
||||
}
|
||||
|
||||
# Determine expected format from extension if not specified
|
||||
if expected_format is None:
|
||||
expected_format = _ext_to_format(Path(path).suffix)
|
||||
|
||||
fmt_info = EXPORT_FORMATS.get(expected_format)
|
||||
if fmt_info and fmt_info["magic"]:
|
||||
with open(path, "rb") as f:
|
||||
header = f.read(max(len(fmt_info["magic"]), 5))
|
||||
|
||||
magic = fmt_info["magic"]
|
||||
# Special handling for MP3 (can start with ID3 tag or sync bytes)
|
||||
if expected_format == "mp3":
|
||||
result["valid"] = (
|
||||
header[:2] == b"\xff\xfb"
|
||||
or header[:3] == b"ID3"
|
||||
)
|
||||
else:
|
||||
result["valid"] = header[:len(magic)] == magic
|
||||
else:
|
||||
# No magic bytes to check; just verify non-empty
|
||||
result["valid"] = size > 0
|
||||
|
||||
result["format"] = expected_format
|
||||
return result
|
||||
|
||||
|
||||
def _ext_to_format(ext: str) -> str:
|
||||
"""Map file extension to format name."""
|
||||
ext = ext.lower().lstrip(".")
|
||||
mapping = {
|
||||
"pdf": "pdf",
|
||||
"png": "png",
|
||||
"svg": "svg",
|
||||
"mp3": "mp3",
|
||||
"flac": "flac",
|
||||
"wav": "wav",
|
||||
"mid": "midi",
|
||||
"midi": "midi",
|
||||
"musicxml": "musicxml",
|
||||
"xml": "musicxml",
|
||||
"mscz": "mscz",
|
||||
"brf": "braille",
|
||||
}
|
||||
return mapping.get(ext, ext)
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Instrument management — list, add, remove, reorder.
|
||||
|
||||
For listing, uses mscore --score-meta. For add/remove/reorder,
|
||||
manipulates the MSCX XML directly.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from cli_anything.musescore.utils import musescore_backend as backend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from cli_anything.musescore.utils import mscx_xml as xml_utils
|
||||
|
||||
|
||||
def list_instruments(path: str) -> list[dict]:
|
||||
"""List instruments in a score.
|
||||
|
||||
Tries mscore --score-meta first, falls back to XML parsing.
|
||||
"""
|
||||
if not os.path.isfile(path):
|
||||
raise FileNotFoundError(f"Score file not found: {path}")
|
||||
|
||||
# Try mscore metadata
|
||||
try:
|
||||
meta = backend.get_score_meta(path)
|
||||
parts = meta.get("parts", [])
|
||||
return [
|
||||
{
|
||||
"index": i,
|
||||
"name": p.get("name", f"Instrument {i+1}"),
|
||||
"instrumentId": p.get("instrumentId", ""),
|
||||
"program": p.get("program", 0),
|
||||
}
|
||||
for i, p in enumerate(parts)
|
||||
]
|
||||
except Exception as e:
|
||||
logger.debug("mscore metadata failed for instruments, falling back to XML: %s", e)
|
||||
|
||||
# Fallback: XML parsing
|
||||
try:
|
||||
tree = xml_utils.read_score_tree(path)
|
||||
instruments = xml_utils.get_instruments(tree)
|
||||
return [
|
||||
{
|
||||
"index": i,
|
||||
"name": inst.get("name") or inst.get("part_name", f"Instrument {i+1}"),
|
||||
"instrumentId": inst.get("id", ""),
|
||||
}
|
||||
for i, inst in enumerate(instruments)
|
||||
]
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Could not list instruments: {e}")
|
||||
|
||||
|
||||
def add_instrument(path: str, output_path: str, instrument_id: str,
|
||||
name: str) -> dict:
|
||||
"""Add an instrument to a .mscz score via MSCX XML manipulation.
|
||||
|
||||
Args:
|
||||
path: Path to input .mscz file.
|
||||
output_path: Path to output .mscz file.
|
||||
instrument_id: MuseScore instrument ID (e.g., "keyboard.piano").
|
||||
name: Display name for the instrument.
|
||||
|
||||
Returns:
|
||||
Dict with result info.
|
||||
"""
|
||||
fmt = xml_utils.detect_format(path)
|
||||
if fmt != "mscz":
|
||||
raise ValueError("Instrument manipulation requires .mscz format")
|
||||
|
||||
data = xml_utils.read_mscz(path)
|
||||
root = data["mscx"].getroot()
|
||||
|
||||
# Find or create the Score element
|
||||
score = root.find(".//Score")
|
||||
if score is None:
|
||||
raise RuntimeError("No <Score> element found in MSCX")
|
||||
|
||||
# Count existing parts BEFORE adding the new one
|
||||
import xml.etree.ElementTree as ET
|
||||
staff_id = str(len(score.findall("Part")) + 1)
|
||||
|
||||
part = ET.SubElement(score, "Part")
|
||||
staff = ET.SubElement(part, "Staff")
|
||||
staff.set("id", staff_id)
|
||||
|
||||
instrument = ET.SubElement(part, "Instrument")
|
||||
instrument.set("id", instrument_id)
|
||||
long_name = ET.SubElement(instrument, "longName")
|
||||
long_name.text = name
|
||||
short_name = ET.SubElement(instrument, "shortName")
|
||||
short_name.text = name[:3]
|
||||
|
||||
# Also add a Staff element at the score level
|
||||
score_staff = ET.SubElement(score, "Staff")
|
||||
score_staff.set("id", staff_id)
|
||||
|
||||
xml_utils.write_mscz(output_path, data)
|
||||
|
||||
return {
|
||||
"action": "add",
|
||||
"instrument_id": instrument_id,
|
||||
"name": name,
|
||||
"output": str(Path(output_path).resolve()),
|
||||
}
|
||||
|
||||
|
||||
def remove_instrument(path: str, output_path: str,
|
||||
instrument_name: str) -> dict:
|
||||
"""Remove an instrument from a .mscz score.
|
||||
|
||||
Args:
|
||||
path: Path to input .mscz file.
|
||||
output_path: Path to output .mscz file.
|
||||
instrument_name: Name of the instrument to remove (case-insensitive).
|
||||
|
||||
Returns:
|
||||
Dict with result info.
|
||||
"""
|
||||
fmt = xml_utils.detect_format(path)
|
||||
if fmt != "mscz":
|
||||
raise ValueError("Instrument manipulation requires .mscz format")
|
||||
|
||||
data = xml_utils.read_mscz(path)
|
||||
root = data["mscx"].getroot()
|
||||
|
||||
score = root.find(".//Score")
|
||||
if score is None:
|
||||
raise RuntimeError("No <Score> element found in MSCX")
|
||||
|
||||
# Find the part to remove
|
||||
removed = False
|
||||
for part in score.findall("Part"):
|
||||
inst = part.find("Instrument")
|
||||
if inst is not None:
|
||||
ln = inst.find("longName")
|
||||
name = ln.text if ln is not None else ""
|
||||
if name.lower() == instrument_name.lower():
|
||||
# Get staff ID before removing
|
||||
staff_elem = part.find("Staff")
|
||||
staff_id = staff_elem.get("id") if staff_elem is not None else None
|
||||
|
||||
score.remove(part)
|
||||
|
||||
# Also remove corresponding score-level Staff
|
||||
if staff_id:
|
||||
for s in score.findall("Staff"):
|
||||
if s.get("id") == staff_id:
|
||||
score.remove(s)
|
||||
break
|
||||
|
||||
removed = True
|
||||
break
|
||||
|
||||
if not removed:
|
||||
raise ValueError(f"Instrument '{instrument_name}' not found")
|
||||
|
||||
xml_utils.write_mscz(output_path, data)
|
||||
|
||||
return {
|
||||
"action": "remove",
|
||||
"instrument_name": instrument_name,
|
||||
"output": str(Path(output_path).resolve()),
|
||||
}
|
||||
|
||||
|
||||
def reorder_instruments(path: str, output_path: str,
|
||||
new_order: list[str]) -> dict:
|
||||
"""Reorder instruments in a .mscz score.
|
||||
|
||||
Args:
|
||||
path: Path to input .mscz file.
|
||||
output_path: Path to output .mscz file.
|
||||
new_order: List of instrument names in desired order.
|
||||
|
||||
Returns:
|
||||
Dict with result info.
|
||||
"""
|
||||
fmt = xml_utils.detect_format(path)
|
||||
if fmt != "mscz":
|
||||
raise ValueError("Instrument manipulation requires .mscz format")
|
||||
|
||||
data = xml_utils.read_mscz(path)
|
||||
root = data["mscx"].getroot()
|
||||
|
||||
score = root.find(".//Score")
|
||||
if score is None:
|
||||
raise RuntimeError("No <Score> element found in MSCX")
|
||||
|
||||
# Collect parts by name
|
||||
parts_by_name = {}
|
||||
for part in score.findall("Part"):
|
||||
inst = part.find("Instrument")
|
||||
if inst is not None:
|
||||
ln = inst.find("longName")
|
||||
name = ln.text if ln is not None else ""
|
||||
parts_by_name[name.lower()] = part
|
||||
|
||||
# Validate: new_order must contain all instruments
|
||||
provided = {n.lower() for n in new_order}
|
||||
existing = set(parts_by_name.keys())
|
||||
missing = existing - provided
|
||||
if missing:
|
||||
missing_names = [n for n in parts_by_name if n in missing]
|
||||
raise ValueError(
|
||||
f"new_order is missing instruments: {missing_names}. "
|
||||
f"All instruments must be included to prevent data loss."
|
||||
)
|
||||
unknown = provided - existing
|
||||
if unknown:
|
||||
raise ValueError(f"Instruments not found in score: {list(unknown)}")
|
||||
|
||||
# Remove all parts
|
||||
for part in score.findall("Part"):
|
||||
score.remove(part)
|
||||
|
||||
# Re-add in new order
|
||||
for name in new_order:
|
||||
score.append(parts_by_name[name.lower()])
|
||||
|
||||
xml_utils.write_mscz(output_path, data)
|
||||
|
||||
return {
|
||||
"action": "reorder",
|
||||
"new_order": new_order,
|
||||
"output": str(Path(output_path).resolve()),
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Media operations — probe, diff, stats."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from cli_anything.musescore.utils import musescore_backend as backend
|
||||
from cli_anything.musescore.utils import mscx_xml as xml_utils
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def probe_score(path: str) -> dict:
|
||||
"""Get comprehensive metadata about a score.
|
||||
|
||||
Combines mscore --score-meta with XML parsing for a rich result.
|
||||
"""
|
||||
if not os.path.isfile(path):
|
||||
raise FileNotFoundError(f"Score file not found: {path}")
|
||||
|
||||
result = {
|
||||
"path": str(Path(path).resolve()),
|
||||
"format": xml_utils.detect_format(path),
|
||||
"size_bytes": os.path.getsize(path),
|
||||
}
|
||||
|
||||
# mscore metadata
|
||||
try:
|
||||
meta = backend.get_score_meta(path)
|
||||
result["metadata"] = meta
|
||||
except Exception as e:
|
||||
logger.debug("mscore metadata failed for probe, falling back to XML: %s", e)
|
||||
# XML fallback
|
||||
try:
|
||||
tree = xml_utils.read_score_tree(path)
|
||||
result["metadata"] = {
|
||||
"title": xml_utils.get_score_title(tree),
|
||||
"key_signature": xml_utils.get_key_signature(tree),
|
||||
"time_signature": xml_utils.get_time_signature(tree),
|
||||
"instruments": xml_utils.get_instruments(tree),
|
||||
"measures": xml_utils.count_measures(tree),
|
||||
"notes": xml_utils.count_notes(tree),
|
||||
}
|
||||
except Exception as e:
|
||||
result["error"] = str(e)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def diff_scores(path_a: str, path_b: str, raw: bool = False) -> dict:
|
||||
"""Diff two scores using mscore --diff.
|
||||
|
||||
Args:
|
||||
path_a: Path to first score.
|
||||
path_b: Path to second score.
|
||||
raw: If True, use --raw-diff.
|
||||
|
||||
Returns:
|
||||
Dict with diff results.
|
||||
"""
|
||||
for p in [path_a, path_b]:
|
||||
if not os.path.isfile(p):
|
||||
raise FileNotFoundError(f"Score file not found: {p}")
|
||||
|
||||
diff_data = backend.diff_scores(path_a, path_b, raw=raw)
|
||||
|
||||
return {
|
||||
"file_a": str(Path(path_a).resolve()),
|
||||
"file_b": str(Path(path_b).resolve()),
|
||||
"raw": raw,
|
||||
"diff": diff_data,
|
||||
}
|
||||
|
||||
|
||||
def score_stats(path: str) -> dict:
|
||||
"""Compute statistics about a score from XML analysis.
|
||||
|
||||
Returns note count, measure count, instrument count,
|
||||
key signature, time signature, etc.
|
||||
"""
|
||||
if not os.path.isfile(path):
|
||||
raise FileNotFoundError(f"Score file not found: {path}")
|
||||
|
||||
result = {
|
||||
"path": str(Path(path).resolve()),
|
||||
"format": xml_utils.detect_format(path),
|
||||
"size_bytes": os.path.getsize(path),
|
||||
}
|
||||
|
||||
try:
|
||||
tree = xml_utils.read_score_tree(path)
|
||||
key_int = xml_utils.get_key_signature(tree)
|
||||
result["stats"] = {
|
||||
"title": xml_utils.get_score_title(tree),
|
||||
"measures": xml_utils.count_measures(tree),
|
||||
"notes": xml_utils.count_notes(tree),
|
||||
"instruments": len(xml_utils.get_instruments(tree)),
|
||||
"key_signature": key_int,
|
||||
"key_name": xml_utils.key_int_to_name(key_int) if key_int is not None else None,
|
||||
"time_signature": xml_utils.get_time_signature(tree),
|
||||
}
|
||||
except Exception as e:
|
||||
result["error"] = str(e)
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Part extraction and management."""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from cli_anything.musescore.utils import musescore_backend as backend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def list_parts(path: str) -> list[dict]:
|
||||
"""List all parts in a score.
|
||||
|
||||
Returns:
|
||||
List of dicts with part name, instrumentId, etc.
|
||||
"""
|
||||
if not os.path.isfile(path):
|
||||
raise FileNotFoundError(f"Score file not found: {path}")
|
||||
|
||||
# Try score-meta first (lighter)
|
||||
try:
|
||||
meta = backend.get_score_meta(path)
|
||||
parts = meta.get("parts", [])
|
||||
return [
|
||||
{
|
||||
"index": i,
|
||||
"name": p.get("name", f"Part {i+1}"),
|
||||
"instrumentId": p.get("instrumentId", ""),
|
||||
"program": p.get("program", 0),
|
||||
"lyricCount": p.get("lyricCount", 0),
|
||||
"harmonyCount": p.get("harmonyCount", 0),
|
||||
}
|
||||
for i, p in enumerate(parts)
|
||||
]
|
||||
except Exception as e:
|
||||
logger.debug("mscore metadata failed for parts, falling back to score-parts: %s", e)
|
||||
|
||||
# Fallback: score-parts (parts = list of names, partsMeta = list of dicts)
|
||||
try:
|
||||
parts_data = backend.get_score_parts(path)
|
||||
part_names = parts_data.get("parts", [])
|
||||
part_meta = parts_data.get("partsMeta", [])
|
||||
return [
|
||||
{
|
||||
"index": i,
|
||||
"name": part_names[i] if i < len(part_names) else f"Part {i+1}",
|
||||
"id": part_meta[i].get("id", "") if i < len(part_meta) else "",
|
||||
}
|
||||
for i in range(max(len(part_names), len(part_meta)))
|
||||
]
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Could not list parts: {e}")
|
||||
|
||||
|
||||
def extract_part(path: str, part_name: str, output_path: str) -> dict:
|
||||
"""Extract a single part from a score.
|
||||
|
||||
Uses --score-parts to get base64-encoded .mscz data for each part,
|
||||
then writes the matching part to the output file.
|
||||
|
||||
Args:
|
||||
path: Path to the input score.
|
||||
part_name: Name of the part to extract.
|
||||
output_path: Path to write the extracted part.
|
||||
|
||||
Returns:
|
||||
Dict with extraction result info.
|
||||
"""
|
||||
if not os.path.isfile(path):
|
||||
raise FileNotFoundError(f"Score file not found: {path}")
|
||||
|
||||
parts_data = backend.get_score_parts(path)
|
||||
part_names = parts_data.get("parts", [])
|
||||
part_bins = parts_data.get("partsBin", [])
|
||||
|
||||
# Find matching part index (case-insensitive)
|
||||
match_idx = None
|
||||
for i, name in enumerate(part_names):
|
||||
if name.lower() == part_name.lower():
|
||||
match_idx = i
|
||||
break
|
||||
|
||||
if match_idx is None:
|
||||
raise ValueError(
|
||||
f"Part '{part_name}' not found. Available parts: {part_names}"
|
||||
)
|
||||
|
||||
if match_idx >= len(part_bins):
|
||||
raise RuntimeError(f"No binary data for part '{part_name}'")
|
||||
|
||||
part_data = part_bins[match_idx]
|
||||
if not part_data:
|
||||
raise RuntimeError(f"No data for part '{part_name}'")
|
||||
|
||||
decoded = base64.b64decode(part_data)
|
||||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(decoded)
|
||||
|
||||
return {
|
||||
"part_name": part_names[match_idx],
|
||||
"output": str(Path(output_path).resolve()),
|
||||
"size_bytes": len(decoded),
|
||||
}
|
||||
|
||||
|
||||
def generate_all_parts(path: str, output_dir: str) -> list[dict]:
|
||||
"""Extract all parts from a score into separate files.
|
||||
|
||||
Args:
|
||||
path: Path to the input score.
|
||||
output_dir: Directory to write part files into.
|
||||
|
||||
Returns:
|
||||
List of dicts with extraction results.
|
||||
"""
|
||||
if not os.path.isfile(path):
|
||||
raise FileNotFoundError(f"Score file not found: {path}")
|
||||
|
||||
parts_data = backend.get_score_parts(path)
|
||||
part_names = parts_data.get("parts", [])
|
||||
part_bins = parts_data.get("partsBin", [])
|
||||
results = []
|
||||
|
||||
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for i, name in enumerate(part_names):
|
||||
if i >= len(part_bins) or not part_bins[i]:
|
||||
continue
|
||||
|
||||
safe_name = name.replace("/", "_").replace("\\", "_").replace(" ", "_")
|
||||
output_path = os.path.join(output_dir, f"{safe_name}.mscz")
|
||||
|
||||
decoded = base64.b64decode(part_bins[i])
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(decoded)
|
||||
|
||||
results.append({
|
||||
"part_name": name,
|
||||
"output": output_path,
|
||||
"size_bytes": len(decoded),
|
||||
})
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Project management — create, open, save, info."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from cli_anything.musescore.utils import musescore_backend as backend
|
||||
from cli_anything.musescore.utils import mscx_xml as xml_utils
|
||||
|
||||
|
||||
def open_project(path: str) -> dict:
|
||||
"""Open a score file and return project data.
|
||||
|
||||
Supports .mscz, .mxl, .musicxml, .mid formats.
|
||||
"""
|
||||
if not os.path.isfile(path):
|
||||
raise FileNotFoundError(f"Score file not found: {path}")
|
||||
|
||||
fmt = xml_utils.detect_format(path)
|
||||
project = {
|
||||
"name": Path(path).stem,
|
||||
"path": str(Path(path).resolve()),
|
||||
"format": fmt,
|
||||
}
|
||||
|
||||
# Try to get metadata from mscore
|
||||
try:
|
||||
meta = backend.get_score_meta(path)
|
||||
project["metadata"] = meta
|
||||
if meta.get("title"):
|
||||
project["name"] = meta["title"]
|
||||
except Exception as e:
|
||||
logger.debug("mscore metadata failed, falling back to XML: %s", e)
|
||||
# Fall back to XML parsing for metadata
|
||||
try:
|
||||
tree = xml_utils.read_score_tree(path)
|
||||
title = xml_utils.get_score_title(tree)
|
||||
if title:
|
||||
project["name"] = title
|
||||
project["metadata"] = {
|
||||
"key_signature": xml_utils.get_key_signature(tree),
|
||||
"time_signature": xml_utils.get_time_signature(tree),
|
||||
"instruments": xml_utils.get_instruments(tree),
|
||||
"measures": xml_utils.count_measures(tree),
|
||||
"notes": xml_utils.count_notes(tree),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug("XML parsing also failed: %s", e)
|
||||
|
||||
return project
|
||||
|
||||
|
||||
def save_project(input_path: str, output_path: str) -> dict:
|
||||
"""Save/convert a score to .mscz format via mscore export.
|
||||
|
||||
This delegates to mscore -o which handles all format conversion.
|
||||
"""
|
||||
from cli_anything.musescore.core import export
|
||||
return export.export_score(input_path, output_path, fmt="mscz")
|
||||
|
||||
|
||||
def project_info(path: str) -> dict:
|
||||
"""Get comprehensive info about a score file."""
|
||||
if not os.path.isfile(path):
|
||||
raise FileNotFoundError(f"Score file not found: {path}")
|
||||
|
||||
info = {
|
||||
"path": str(Path(path).resolve()),
|
||||
"format": xml_utils.detect_format(path),
|
||||
"size_bytes": os.path.getsize(path),
|
||||
}
|
||||
|
||||
# Try mscore --score-meta first (most complete)
|
||||
try:
|
||||
meta = backend.get_score_meta(path)
|
||||
info["metadata"] = meta
|
||||
return info
|
||||
except Exception as e:
|
||||
logger.debug("mscore metadata failed for info, falling back to XML: %s", e)
|
||||
|
||||
# Fall back to XML parsing
|
||||
try:
|
||||
tree = xml_utils.read_score_tree(path)
|
||||
info["metadata"] = {
|
||||
"title": xml_utils.get_score_title(tree),
|
||||
"key_signature": xml_utils.get_key_signature(tree),
|
||||
"key_name": _key_sig_name(xml_utils.get_key_signature(tree)),
|
||||
"time_signature": xml_utils.get_time_signature(tree),
|
||||
"instruments": xml_utils.get_instruments(tree),
|
||||
"measures": xml_utils.count_measures(tree),
|
||||
"notes": xml_utils.count_notes(tree),
|
||||
}
|
||||
except Exception as e:
|
||||
info["error"] = f"Could not parse score: {e}"
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def _key_sig_name(key_int: int | None) -> str | None:
|
||||
if key_int is None:
|
||||
return None
|
||||
try:
|
||||
return xml_utils.key_int_to_name(key_int)
|
||||
except ValueError:
|
||||
return f"keysig={key_int}"
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Session management with undo/redo and JSON persistence.
|
||||
|
||||
Maintains in-memory state for the currently open project, with
|
||||
undo/redo stacks and safe file locking for concurrent access.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError:
|
||||
fcntl = None # Windows — file locking unavailable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class Session:
|
||||
"""Stateful session for the MuseScore CLI."""
|
||||
|
||||
project_path: str | None = None
|
||||
project_data: dict | None = None
|
||||
modified: bool = False
|
||||
undo_stack: list[dict] = field(default_factory=list)
|
||||
redo_stack: list[dict] = field(default_factory=list)
|
||||
history: list[str] = field(default_factory=list)
|
||||
|
||||
def has_project(self) -> bool:
|
||||
return self.project_data is not None
|
||||
|
||||
def get_project(self) -> dict:
|
||||
if self.project_data is None:
|
||||
raise RuntimeError("No project is open. Use 'project open' first.")
|
||||
return self.project_data
|
||||
|
||||
def set_project(self, data: dict, path: str | None = None):
|
||||
self.project_data = data
|
||||
self.project_path = path
|
||||
self.modified = False
|
||||
self.undo_stack.clear()
|
||||
self.redo_stack.clear()
|
||||
self.history.clear()
|
||||
|
||||
def is_modified(self) -> bool:
|
||||
return self.modified
|
||||
|
||||
def snapshot(self, description: str):
|
||||
"""Save current state to undo stack before a modification."""
|
||||
if self.project_data is not None:
|
||||
self.undo_stack.append({
|
||||
"description": description,
|
||||
"data": copy.deepcopy(self.project_data),
|
||||
"path": self.project_path,
|
||||
})
|
||||
self.redo_stack.clear()
|
||||
self.history.append(description)
|
||||
self.modified = True
|
||||
|
||||
def undo(self) -> str:
|
||||
"""Undo the last operation."""
|
||||
if not self.undo_stack:
|
||||
raise RuntimeError("Nothing to undo.")
|
||||
entry = self.undo_stack.pop()
|
||||
self.redo_stack.append({
|
||||
"description": entry["description"],
|
||||
"data": copy.deepcopy(self.project_data),
|
||||
"path": self.project_path,
|
||||
})
|
||||
self.project_data = entry["data"]
|
||||
self.project_path = entry.get("path", self.project_path)
|
||||
self.modified = True
|
||||
return entry["description"]
|
||||
|
||||
def redo(self) -> str:
|
||||
"""Redo the last undone operation."""
|
||||
if not self.redo_stack:
|
||||
raise RuntimeError("Nothing to redo.")
|
||||
entry = self.redo_stack.pop()
|
||||
self.undo_stack.append({
|
||||
"description": entry["description"],
|
||||
"data": copy.deepcopy(self.project_data),
|
||||
"path": self.project_path,
|
||||
})
|
||||
self.project_data = entry["data"]
|
||||
self.project_path = entry.get("path", self.project_path)
|
||||
self.modified = True
|
||||
return entry["description"]
|
||||
|
||||
def list_history(self) -> list[str]:
|
||||
return list(self.history)
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"project_path": self.project_path or "(none)",
|
||||
"modified": self.modified,
|
||||
"undo_depth": len(self.undo_stack),
|
||||
"redo_depth": len(self.redo_stack),
|
||||
"history_length": len(self.history),
|
||||
}
|
||||
|
||||
def save_session(self, path: str | None = None) -> str:
|
||||
"""Save session state to a JSON file with file locking."""
|
||||
save_path = path or self.project_path
|
||||
if not save_path:
|
||||
raise RuntimeError("No save path specified.")
|
||||
|
||||
session_file = str(save_path) + ".session.json"
|
||||
data = {
|
||||
"project_path": self.project_path,
|
||||
"modified": self.modified,
|
||||
"history": self.history,
|
||||
}
|
||||
_locked_save_json(session_file, data)
|
||||
return session_file
|
||||
|
||||
|
||||
def _locked_save_json(path: str, data: Any):
|
||||
"""Save JSON data with file locking where available.
|
||||
|
||||
Uses fcntl.flock on Unix (r+ mode to avoid truncation before lock).
|
||||
Falls back to plain write on Windows where fcntl is unavailable.
|
||||
"""
|
||||
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if fcntl is not None:
|
||||
# Unix: lock-safe write
|
||||
if not os.path.exists(path):
|
||||
with open(path, "w") as f:
|
||||
f.write("{}")
|
||||
with open(path, "r+") as f:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
f.seek(0)
|
||||
f.truncate()
|
||||
json.dump(data, f, indent=2, default=str)
|
||||
finally:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
else:
|
||||
# Windows: plain write (session state is not concurrent-critical)
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f, indent=2, default=str)
|
||||
|
||||
|
||||
# ── Singleton ─────────────────────────────────────────────────────────
|
||||
|
||||
_session: Session | None = None
|
||||
|
||||
|
||||
def get_session() -> Session:
|
||||
"""Get or create the global session singleton."""
|
||||
global _session
|
||||
if _session is None:
|
||||
_session = Session()
|
||||
return _session
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Transposition logic — by key, by interval, diatonic."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from cli_anything.musescore.utils import musescore_backend as backend
|
||||
from cli_anything.musescore.utils.mscx_xml import key_name_to_int
|
||||
|
||||
|
||||
# ── Interval enum mapping (from MuseScore source) ────────────────────
|
||||
# The transposeInterval field is an index into this enum, NOT semitones.
|
||||
# Index: (name, semitones)
|
||||
INTERVAL_ENUM = [
|
||||
("Perfect Unison", 0),
|
||||
("Minor Second", 1),
|
||||
("Major Second", 2),
|
||||
("Minor Third", 3),
|
||||
("Major Third", 4),
|
||||
("Perfect Fourth", 5),
|
||||
("Augmented Fourth", 6),
|
||||
("Perfect Fifth", 7),
|
||||
("Minor Sixth", 8),
|
||||
("Major Sixth", 9),
|
||||
("Minor Seventh", 10),
|
||||
("Major Seventh", 11),
|
||||
("Perfect Octave", 12),
|
||||
("Minor Ninth", 13),
|
||||
("Major Ninth", 14),
|
||||
("Minor Tenth", 15),
|
||||
("Major Tenth", 16),
|
||||
("Perfect Eleventh", 17),
|
||||
("Augmented Eleventh", 18),
|
||||
("Perfect Twelfth", 19),
|
||||
("Minor Thirteenth", 20),
|
||||
("Major Thirteenth", 21),
|
||||
("Minor Fourteenth", 22),
|
||||
("Major Fourteenth", 23),
|
||||
("Perfect Fifteenth", 24),
|
||||
("Double Augmented Unison", 25),
|
||||
]
|
||||
|
||||
# Semitone → best-fit interval index (first match)
|
||||
_SEMITONES_TO_INTERVAL = {}
|
||||
for _idx, (_name, _semi) in enumerate(INTERVAL_ENUM):
|
||||
if _semi not in _SEMITONES_TO_INTERVAL:
|
||||
_SEMITONES_TO_INTERVAL[_semi] = _idx
|
||||
|
||||
|
||||
def semitones_to_interval_index(semitones: int) -> int:
|
||||
"""Convert a semitone count to the mscore transposeInterval index.
|
||||
|
||||
Args:
|
||||
semitones: Number of semitones (0-24).
|
||||
|
||||
Returns:
|
||||
Index into the MuseScore interval enum.
|
||||
"""
|
||||
abs_semi = abs(semitones) % 25
|
||||
if abs_semi in _SEMITONES_TO_INTERVAL:
|
||||
return _SEMITONES_TO_INTERVAL[abs_semi]
|
||||
raise ValueError(f"No interval mapping for {semitones} semitones")
|
||||
|
||||
|
||||
def transpose_by_key(input_path: str, output_path: str, *,
|
||||
target_key: str,
|
||||
direction: str = "closest",
|
||||
transpose_key_signatures: bool = True,
|
||||
transpose_chord_names: bool = True,
|
||||
use_double_sharps_flats: bool = False) -> dict:
|
||||
"""Transpose a score to a target key.
|
||||
|
||||
Args:
|
||||
input_path: Path to input score.
|
||||
output_path: Path to output score.
|
||||
target_key: Key name (e.g., "C major", "Db", "Am").
|
||||
direction: "up", "down", or "closest".
|
||||
transpose_key_signatures: Whether to transpose key signatures.
|
||||
transpose_chord_names: Whether to transpose chord names.
|
||||
use_double_sharps_flats: Whether to use double sharps/flats.
|
||||
|
||||
Returns:
|
||||
Dict with result info.
|
||||
"""
|
||||
key_int = key_name_to_int(target_key)
|
||||
|
||||
opts = {
|
||||
"mode": "to_key",
|
||||
"direction": direction,
|
||||
"targetKey": key_int,
|
||||
"transposeKeySignatures": transpose_key_signatures,
|
||||
"transposeChordNames": transpose_chord_names,
|
||||
"useDoubleSharpsFlats": use_double_sharps_flats,
|
||||
}
|
||||
|
||||
result_path = backend.transpose_score(input_path, output_path, opts)
|
||||
|
||||
return {
|
||||
"input": input_path,
|
||||
"output": str(result_path),
|
||||
"mode": "to_key",
|
||||
"target_key": target_key,
|
||||
"target_key_int": key_int,
|
||||
"direction": direction,
|
||||
}
|
||||
|
||||
|
||||
def transpose_by_interval(input_path: str, output_path: str, *,
|
||||
semitones: int | None = None,
|
||||
interval_index: int | None = None,
|
||||
direction: str = "up",
|
||||
transpose_key_signatures: bool = True,
|
||||
transpose_chord_names: bool = True,
|
||||
use_double_sharps_flats: bool = False) -> dict:
|
||||
"""Transpose a score by a chromatic interval.
|
||||
|
||||
Specify either semitones or interval_index (not both).
|
||||
|
||||
Args:
|
||||
semitones: Number of semitones to transpose.
|
||||
interval_index: Direct mscore interval enum index (0-25).
|
||||
direction: "up" or "down".
|
||||
"""
|
||||
if semitones is not None and interval_index is not None:
|
||||
raise ValueError("Specify either semitones or interval_index, not both.")
|
||||
if semitones is None and interval_index is None:
|
||||
raise ValueError("Must specify either semitones or interval_index.")
|
||||
|
||||
if semitones is not None:
|
||||
if semitones < 0:
|
||||
direction = "down"
|
||||
semitones = abs(semitones)
|
||||
idx = semitones_to_interval_index(semitones)
|
||||
else:
|
||||
idx = interval_index
|
||||
|
||||
opts = {
|
||||
"mode": "by_interval",
|
||||
"direction": direction,
|
||||
"transposeInterval": idx,
|
||||
"transposeKeySignatures": transpose_key_signatures,
|
||||
"transposeChordNames": transpose_chord_names,
|
||||
"useDoubleSharpsFlats": use_double_sharps_flats,
|
||||
}
|
||||
|
||||
result_path = backend.transpose_score(input_path, output_path, opts)
|
||||
|
||||
return {
|
||||
"input": input_path,
|
||||
"output": str(result_path),
|
||||
"mode": "by_interval",
|
||||
"interval_index": idx,
|
||||
"direction": direction,
|
||||
}
|
||||
|
||||
|
||||
def transpose_diatonic(input_path: str, output_path: str, *,
|
||||
steps: int,
|
||||
direction: str = "up",
|
||||
transpose_key_signatures: bool = True,
|
||||
transpose_chord_names: bool = True,
|
||||
use_double_sharps_flats: bool = False) -> dict:
|
||||
"""Transpose a score diatonically by a number of steps.
|
||||
|
||||
Args:
|
||||
steps: Number of diatonic steps.
|
||||
direction: "up" or "down".
|
||||
"""
|
||||
if steps < 0:
|
||||
direction = "down"
|
||||
steps = abs(steps)
|
||||
|
||||
opts = {
|
||||
"mode": "diatonically",
|
||||
"direction": direction,
|
||||
"transposeInterval": steps,
|
||||
"transposeKeySignatures": transpose_key_signatures,
|
||||
"transposeChordNames": transpose_chord_names,
|
||||
"useDoubleSharpsFlats": use_double_sharps_flats,
|
||||
}
|
||||
|
||||
result_path = backend.transpose_score(input_path, output_path, opts)
|
||||
|
||||
return {
|
||||
"input": input_path,
|
||||
"output": str(result_path),
|
||||
"mode": "diatonically",
|
||||
"steps": steps,
|
||||
"direction": direction,
|
||||
}
|
||||
@@ -0,0 +1,618 @@
|
||||
#!/usr/bin/env python3
|
||||
"""MuseScore CLI — A stateful command-line interface for music notation.
|
||||
|
||||
This CLI wraps MuseScore 4's mscore backend, providing transposition,
|
||||
export (PDF/audio/MIDI), part extraction, instrument management, and
|
||||
score analysis from the command line.
|
||||
|
||||
Usage:
|
||||
cli-anything-musescore --json project info -i score.mscz
|
||||
cli-anything-musescore --json transpose by-key -i score.mscz -o out.mscz --target-key "C major"
|
||||
cli-anything-musescore --json export pdf -i score.mscz -o score.pdf
|
||||
cli-anything-musescore # Enter interactive REPL
|
||||
"""
|
||||
|
||||
import functools
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import click
|
||||
from typing import Optional
|
||||
|
||||
from cli_anything.musescore.core.session import Session, get_session
|
||||
from cli_anything.musescore.core import project as proj_mod
|
||||
from cli_anything.musescore.core import transpose as trans_mod
|
||||
from cli_anything.musescore.core import parts as parts_mod
|
||||
from cli_anything.musescore.core import export as export_mod
|
||||
from cli_anything.musescore.core import instruments as inst_mod
|
||||
from cli_anything.musescore.core import media as media_mod
|
||||
|
||||
_json_output = False
|
||||
_repl_mode = False
|
||||
|
||||
|
||||
def output(data, message: str = ""):
|
||||
"""Output data as JSON or human-readable."""
|
||||
if _json_output:
|
||||
click.echo(json.dumps(data, indent=2, default=str))
|
||||
else:
|
||||
if message:
|
||||
click.echo(message)
|
||||
if isinstance(data, dict):
|
||||
_print_dict(data)
|
||||
elif isinstance(data, list):
|
||||
_print_list(data)
|
||||
else:
|
||||
click.echo(str(data))
|
||||
|
||||
|
||||
def _print_dict(d: dict, indent: int = 0):
|
||||
prefix = " " * indent
|
||||
for k, v in d.items():
|
||||
if isinstance(v, dict):
|
||||
click.echo(f"{prefix}{k}:")
|
||||
_print_dict(v, indent + 1)
|
||||
elif isinstance(v, list):
|
||||
click.echo(f"{prefix}{k}:")
|
||||
_print_list(v, indent + 1)
|
||||
else:
|
||||
click.echo(f"{prefix}{k}: {v}")
|
||||
|
||||
|
||||
def _print_list(items: list, indent: int = 0):
|
||||
prefix = " " * indent
|
||||
for i, item in enumerate(items):
|
||||
if isinstance(item, dict):
|
||||
click.echo(f"{prefix}[{i}]")
|
||||
_print_dict(item, indent + 1)
|
||||
else:
|
||||
click.echo(f"{prefix}- {item}")
|
||||
|
||||
|
||||
def handle_error(func):
|
||||
"""Decorator for consistent error handling across commands."""
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except FileNotFoundError as e:
|
||||
if _json_output:
|
||||
click.echo(json.dumps({"error": str(e), "type": "file_not_found"}))
|
||||
else:
|
||||
click.echo(f"Error: {e}", err=True)
|
||||
if not _repl_mode:
|
||||
sys.exit(1)
|
||||
except (ValueError, IndexError, RuntimeError) as e:
|
||||
if _json_output:
|
||||
click.echo(json.dumps({"error": str(e), "type": type(e).__name__}))
|
||||
else:
|
||||
click.echo(f"Error: {e}", err=True)
|
||||
if not _repl_mode:
|
||||
sys.exit(1)
|
||||
return wrapper
|
||||
|
||||
|
||||
# ── Main CLI Group ────────────────────────────────────────────────────
|
||||
|
||||
@click.group(invoke_without_command=True)
|
||||
@click.option("--json", "use_json", is_flag=True, help="Output as JSON")
|
||||
@click.option("--project", "project_path", type=str, default=None,
|
||||
help="Path to score file to open")
|
||||
@click.pass_context
|
||||
def cli(ctx, use_json, project_path):
|
||||
"""MuseScore CLI — Music notation from the command line.
|
||||
|
||||
Run without a subcommand to enter interactive REPL mode.
|
||||
"""
|
||||
global _json_output
|
||||
_json_output = use_json
|
||||
|
||||
if project_path:
|
||||
sess = get_session()
|
||||
if not sess.has_project():
|
||||
proj = proj_mod.open_project(project_path)
|
||||
sess.set_project(proj, project_path)
|
||||
|
||||
if ctx.invoked_subcommand is None:
|
||||
ctx.invoke(repl)
|
||||
|
||||
|
||||
# ── Project Commands ──────────────────────────────────────────────────
|
||||
|
||||
@cli.group()
|
||||
def project():
|
||||
"""Project management commands."""
|
||||
pass
|
||||
|
||||
|
||||
@project.command("open")
|
||||
@click.option("-i", "--input", "path", required=True, help="Score file path")
|
||||
@handle_error
|
||||
def project_open(path):
|
||||
"""Open a score file."""
|
||||
proj = proj_mod.open_project(path)
|
||||
sess = get_session()
|
||||
sess.set_project(proj, path)
|
||||
output(proj, f"Opened: {path}")
|
||||
|
||||
|
||||
@project.command("info")
|
||||
@click.option("-i", "--input", "path", required=True, help="Score file path")
|
||||
@handle_error
|
||||
def project_info(path):
|
||||
"""Show score information."""
|
||||
info = proj_mod.project_info(path)
|
||||
output(info)
|
||||
|
||||
|
||||
@project.command("save")
|
||||
@click.option("-i", "--input", "input_path", required=True, help="Input score file")
|
||||
@click.option("-o", "--output", "output_path", required=True, help="Output score file")
|
||||
@handle_error
|
||||
def project_save(input_path, output_path):
|
||||
"""Save/convert a score to .mscz format via mscore export."""
|
||||
from cli_anything.musescore.core import export as export_mod_local
|
||||
result = export_mod_local.export_score(input_path, output_path, fmt="mscz")
|
||||
output(result, f"Saved to: {output_path}")
|
||||
|
||||
|
||||
# ── Transpose Commands ────────────────────────────────────────────────
|
||||
|
||||
@cli.group()
|
||||
def transpose():
|
||||
"""Transposition commands."""
|
||||
pass
|
||||
|
||||
|
||||
@transpose.command("by-key")
|
||||
@click.option("-i", "--input", "input_path", required=True, help="Input score")
|
||||
@click.option("-o", "--output", "output_path", required=True, help="Output score")
|
||||
@click.option("--target-key", required=True, help="Target key (e.g., 'C major', 'Db', 'Am')")
|
||||
@click.option("--direction", type=click.Choice(["up", "down", "closest"]),
|
||||
default="closest", help="Transpose direction")
|
||||
@click.option("--no-key-sig", is_flag=True, help="Don't transpose key signatures")
|
||||
@click.option("--no-chord-names", is_flag=True, help="Don't transpose chord names")
|
||||
@handle_error
|
||||
def transpose_by_key(input_path, output_path, target_key, direction,
|
||||
no_key_sig, no_chord_names):
|
||||
"""Transpose to a target key."""
|
||||
sess = get_session()
|
||||
if sess.has_project():
|
||||
sess.snapshot(f"Transpose to {target_key}")
|
||||
|
||||
result = trans_mod.transpose_by_key(
|
||||
input_path, output_path,
|
||||
target_key=target_key,
|
||||
direction=direction,
|
||||
transpose_key_signatures=not no_key_sig,
|
||||
transpose_chord_names=not no_chord_names,
|
||||
)
|
||||
|
||||
# Update session state from output file
|
||||
if sess.has_project() and sess.project_path == input_path:
|
||||
updated = proj_mod.open_project(output_path)
|
||||
sess.project_data.update(updated)
|
||||
sess.project_path = output_path
|
||||
|
||||
output(result, f"Transposed to {target_key}")
|
||||
|
||||
|
||||
@transpose.command("by-interval")
|
||||
@click.option("-i", "--input", "input_path", required=True, help="Input score")
|
||||
@click.option("-o", "--output", "output_path", required=True, help="Output score")
|
||||
@click.option("--semitones", type=int, default=None, help="Semitones (negative = down)")
|
||||
@click.option("--interval", "interval_index", type=int, default=None,
|
||||
help="MuseScore interval index (0-25)")
|
||||
@click.option("--direction", type=click.Choice(["up", "down"]),
|
||||
default="up", help="Transpose direction")
|
||||
@click.option("--no-key-sig", is_flag=True, help="Don't transpose key signatures")
|
||||
@click.option("--no-chord-names", is_flag=True, help="Don't transpose chord names")
|
||||
@handle_error
|
||||
def transpose_by_interval(input_path, output_path, semitones, interval_index,
|
||||
direction, no_key_sig, no_chord_names):
|
||||
"""Transpose by a chromatic interval."""
|
||||
sess = get_session()
|
||||
if sess.has_project():
|
||||
sess.snapshot(f"Transpose by interval")
|
||||
|
||||
result = trans_mod.transpose_by_interval(
|
||||
input_path, output_path,
|
||||
semitones=semitones,
|
||||
interval_index=interval_index,
|
||||
direction=direction,
|
||||
transpose_key_signatures=not no_key_sig,
|
||||
transpose_chord_names=not no_chord_names,
|
||||
)
|
||||
|
||||
# Update session state from output file
|
||||
if sess.has_project() and sess.project_path == input_path:
|
||||
updated = proj_mod.open_project(output_path)
|
||||
sess.project_data.update(updated)
|
||||
sess.project_path = output_path
|
||||
|
||||
output(result, "Transposed by interval")
|
||||
|
||||
|
||||
@transpose.command("diatonic")
|
||||
@click.option("-i", "--input", "input_path", required=True, help="Input score")
|
||||
@click.option("-o", "--output", "output_path", required=True, help="Output score")
|
||||
@click.option("--steps", type=int, required=True, help="Diatonic steps (negative = down)")
|
||||
@click.option("--direction", type=click.Choice(["up", "down"]),
|
||||
default="up", help="Transpose direction")
|
||||
@click.option("--no-key-sig", is_flag=True, help="Don't transpose key signatures")
|
||||
@click.option("--no-chord-names", is_flag=True, help="Don't transpose chord names")
|
||||
@handle_error
|
||||
def transpose_diatonic(input_path, output_path, steps, direction,
|
||||
no_key_sig, no_chord_names):
|
||||
"""Transpose diatonically."""
|
||||
sess = get_session()
|
||||
if sess.has_project():
|
||||
sess.snapshot(f"Diatonic transpose by {steps}")
|
||||
|
||||
result = trans_mod.transpose_diatonic(
|
||||
input_path, output_path,
|
||||
steps=steps,
|
||||
direction=direction,
|
||||
transpose_key_signatures=not no_key_sig,
|
||||
transpose_chord_names=not no_chord_names,
|
||||
)
|
||||
|
||||
# Update session state from output file
|
||||
if sess.has_project() and sess.project_path == input_path:
|
||||
updated = proj_mod.open_project(output_path)
|
||||
sess.project_data.update(updated)
|
||||
sess.project_path = output_path
|
||||
|
||||
output(result, f"Diatonic transpose by {steps} steps")
|
||||
|
||||
|
||||
# ── Parts Commands ────────────────────────────────────────────────────
|
||||
|
||||
@cli.group()
|
||||
def parts():
|
||||
"""Part extraction and management."""
|
||||
pass
|
||||
|
||||
|
||||
@parts.command("list")
|
||||
@click.option("-i", "--input", "path", required=True, help="Score file path")
|
||||
@handle_error
|
||||
def parts_list(path):
|
||||
"""List all parts in a score."""
|
||||
result = parts_mod.list_parts(path)
|
||||
output(result, "Parts:")
|
||||
|
||||
|
||||
@parts.command("extract")
|
||||
@click.option("-i", "--input", "path", required=True, help="Score file path")
|
||||
@click.option("-o", "--output", "output_path", required=True, help="Output file path")
|
||||
@click.option("--part", "part_name", required=True, help="Part name to extract")
|
||||
@handle_error
|
||||
def parts_extract(path, output_path, part_name):
|
||||
"""Extract a single part from a score."""
|
||||
result = parts_mod.extract_part(path, part_name, output_path)
|
||||
output(result, f"Extracted part: {part_name}")
|
||||
|
||||
|
||||
@parts.command("generate")
|
||||
@click.option("-i", "--input", "path", required=True, help="Score file path")
|
||||
@click.option("-d", "--output-dir", required=True, help="Output directory")
|
||||
@handle_error
|
||||
def parts_generate(path, output_dir):
|
||||
"""Generate all parts as separate files."""
|
||||
result = parts_mod.generate_all_parts(path, output_dir)
|
||||
output(result, f"Generated {len(result)} parts")
|
||||
|
||||
|
||||
# ── Export Commands ───────────────────────────────────────────────────
|
||||
|
||||
@cli.group("export")
|
||||
def export_group():
|
||||
"""Export/render commands."""
|
||||
pass
|
||||
|
||||
|
||||
def _make_export_cmd(fmt_name, description):
|
||||
"""Factory for format-specific export commands."""
|
||||
@export_group.command(fmt_name, help=description)
|
||||
@click.option("-i", "--input", "input_path", required=True, help="Input score")
|
||||
@click.option("-o", "--output", "output_path", required=True, help="Output file")
|
||||
@click.option("--dpi", type=int, default=None, help="DPI for PNG export")
|
||||
@click.option("--bitrate", type=int, default=None, help="Bitrate for MP3 (kbps)")
|
||||
@click.option("--trim", type=int, default=None, help="Trim margin for PNG/SVG")
|
||||
@click.option("--style", type=str, default=None, help="Style file (.mss)")
|
||||
@click.option("--sound-profile", type=str, default=None,
|
||||
help="Audio profile (MuseScore Basic or Muse Sounds)")
|
||||
@handle_error
|
||||
def export_cmd(input_path, output_path, dpi, bitrate, trim, style, sound_profile):
|
||||
result = export_mod.export_score(
|
||||
input_path, output_path, fmt=fmt_name,
|
||||
dpi=dpi, bitrate=bitrate, trim=trim,
|
||||
style=style, sound_profile=sound_profile,
|
||||
)
|
||||
output(result, f"Exported {fmt_name}: {output_path}")
|
||||
|
||||
return export_cmd
|
||||
|
||||
|
||||
# Create format-specific commands
|
||||
_make_export_cmd("pdf", "Export as PDF document")
|
||||
_make_export_cmd("png", "Export as PNG images (one per page)")
|
||||
_make_export_cmd("svg", "Export as SVG vector graphics")
|
||||
_make_export_cmd("mp3", "Export as MP3 audio")
|
||||
_make_export_cmd("flac", "Export as FLAC audio")
|
||||
_make_export_cmd("wav", "Export as WAV audio")
|
||||
_make_export_cmd("midi", "Export as MIDI file")
|
||||
_make_export_cmd("musicxml", "Export as MusicXML")
|
||||
_make_export_cmd("braille", "Export as Braille music notation")
|
||||
|
||||
|
||||
@export_group.command("batch")
|
||||
@click.option("-i", "--input", "input_path", required=True, help="Input score")
|
||||
@click.option("-o", "--output", "outputs", multiple=True, required=True,
|
||||
help="Output files (specify multiple)")
|
||||
@handle_error
|
||||
def export_batch(input_path, outputs):
|
||||
"""Export to multiple formats at once."""
|
||||
result = export_mod.batch_export(input_path, list(outputs))
|
||||
output(result, f"Batch exported {len(result)} files")
|
||||
|
||||
|
||||
@export_group.command("verify")
|
||||
@click.argument("path")
|
||||
@click.option("--format", "fmt", default=None, help="Expected format")
|
||||
@handle_error
|
||||
def export_verify(path, fmt):
|
||||
"""Verify an exported file using magic bytes."""
|
||||
result = export_mod.verify_output(path, fmt)
|
||||
output(result)
|
||||
|
||||
|
||||
# ── Instruments Commands ──────────────────────────────────────────────
|
||||
|
||||
@cli.group()
|
||||
def instruments():
|
||||
"""Instrument management commands."""
|
||||
pass
|
||||
|
||||
|
||||
@instruments.command("list")
|
||||
@click.option("-i", "--input", "path", required=True, help="Score file path")
|
||||
@handle_error
|
||||
def instruments_list(path):
|
||||
"""List instruments in a score."""
|
||||
result = inst_mod.list_instruments(path)
|
||||
output(result, "Instruments:")
|
||||
|
||||
|
||||
@instruments.command("add")
|
||||
@click.option("-i", "--input", "path", required=True, help="Input .mscz file")
|
||||
@click.option("-o", "--output", "output_path", required=True, help="Output .mscz file")
|
||||
@click.option("--id", "instrument_id", required=True, help="Instrument ID")
|
||||
@click.option("--name", required=True, help="Display name")
|
||||
@handle_error
|
||||
def instruments_add(path, output_path, instrument_id, name):
|
||||
"""Add an instrument to a score."""
|
||||
sess = get_session()
|
||||
if sess.has_project():
|
||||
sess.snapshot(f"Add instrument: {name}")
|
||||
result = inst_mod.add_instrument(path, output_path, instrument_id, name)
|
||||
|
||||
# Update session state from output file
|
||||
if sess.has_project() and sess.project_path == path:
|
||||
updated = proj_mod.open_project(output_path)
|
||||
sess.project_data.update(updated)
|
||||
sess.project_path = output_path
|
||||
|
||||
output(result, f"Added instrument: {name}")
|
||||
|
||||
|
||||
@instruments.command("remove")
|
||||
@click.option("-i", "--input", "path", required=True, help="Input .mscz file")
|
||||
@click.option("-o", "--output", "output_path", required=True, help="Output .mscz file")
|
||||
@click.option("--name", required=True, help="Instrument name to remove")
|
||||
@handle_error
|
||||
def instruments_remove(path, output_path, name):
|
||||
"""Remove an instrument from a score."""
|
||||
sess = get_session()
|
||||
if sess.has_project():
|
||||
sess.snapshot(f"Remove instrument: {name}")
|
||||
result = inst_mod.remove_instrument(path, output_path, name)
|
||||
|
||||
# Update session state from output file
|
||||
if sess.has_project() and sess.project_path == path:
|
||||
updated = proj_mod.open_project(output_path)
|
||||
sess.project_data.update(updated)
|
||||
sess.project_path = output_path
|
||||
|
||||
output(result, f"Removed instrument: {name}")
|
||||
|
||||
|
||||
@instruments.command("reorder")
|
||||
@click.option("-i", "--input", "path", required=True, help="Input .mscz file")
|
||||
@click.option("-o", "--output", "output_path", required=True, help="Output .mscz file")
|
||||
@click.option("--order", required=True, help="Comma-separated instrument names")
|
||||
@handle_error
|
||||
def instruments_reorder(path, output_path, order):
|
||||
"""Reorder instruments in a score."""
|
||||
new_order = [n.strip() for n in order.split(",")]
|
||||
sess = get_session()
|
||||
if sess.has_project():
|
||||
sess.snapshot("Reorder instruments")
|
||||
result = inst_mod.reorder_instruments(path, output_path, new_order)
|
||||
|
||||
# Update session state from output file
|
||||
if sess.has_project() and sess.project_path == path:
|
||||
updated = proj_mod.open_project(output_path)
|
||||
sess.project_data.update(updated)
|
||||
sess.project_path = output_path
|
||||
|
||||
output(result, "Reordered instruments")
|
||||
|
||||
|
||||
# ── Media Commands ────────────────────────────────────────────────────
|
||||
|
||||
@cli.group()
|
||||
def media():
|
||||
"""Media analysis commands."""
|
||||
pass
|
||||
|
||||
|
||||
@media.command("probe")
|
||||
@click.option("-i", "--input", "path", required=True, help="Score file path")
|
||||
@handle_error
|
||||
def media_probe(path):
|
||||
"""Probe score metadata."""
|
||||
result = media_mod.probe_score(path)
|
||||
output(result)
|
||||
|
||||
|
||||
@media.command("diff")
|
||||
@click.option("--reference", required=True, help="Reference score")
|
||||
@click.option("--compare", required=True, help="Comparison score")
|
||||
@click.option("--raw", is_flag=True, help="Use raw diff format")
|
||||
@handle_error
|
||||
def media_diff(reference, compare, raw):
|
||||
"""Diff two scores."""
|
||||
result = media_mod.diff_scores(reference, compare, raw=raw)
|
||||
output(result)
|
||||
|
||||
|
||||
@media.command("stats")
|
||||
@click.option("-i", "--input", "path", required=True, help="Score file path")
|
||||
@handle_error
|
||||
def media_stats(path):
|
||||
"""Show score statistics."""
|
||||
result = media_mod.score_stats(path)
|
||||
output(result)
|
||||
|
||||
|
||||
# ── Session Commands ──────────────────────────────────────────────────
|
||||
|
||||
@cli.group("session")
|
||||
def session_group():
|
||||
"""Session management commands."""
|
||||
pass
|
||||
|
||||
|
||||
@session_group.command("status")
|
||||
@handle_error
|
||||
def session_status():
|
||||
"""Show session status."""
|
||||
sess = get_session()
|
||||
output(sess.status())
|
||||
|
||||
|
||||
@session_group.command("undo")
|
||||
@handle_error
|
||||
def session_undo():
|
||||
"""Undo the last operation."""
|
||||
sess = get_session()
|
||||
desc = sess.undo()
|
||||
output({"undone": desc}, f"Undone: {desc}")
|
||||
|
||||
|
||||
@session_group.command("redo")
|
||||
@handle_error
|
||||
def session_redo():
|
||||
"""Redo the last undone operation."""
|
||||
sess = get_session()
|
||||
desc = sess.redo()
|
||||
output({"redone": desc}, f"Redone: {desc}")
|
||||
|
||||
|
||||
@session_group.command("history")
|
||||
@handle_error
|
||||
def session_history():
|
||||
"""Show undo history."""
|
||||
sess = get_session()
|
||||
history = sess.list_history()
|
||||
output(history, "History:")
|
||||
|
||||
|
||||
# ── REPL ──────────────────────────────────────────────────────────────
|
||||
|
||||
@cli.command(hidden=True)
|
||||
@handle_error
|
||||
def repl():
|
||||
"""Start interactive REPL session."""
|
||||
global _repl_mode
|
||||
_repl_mode = True
|
||||
|
||||
from cli_anything.musescore.utils.repl_skin import ReplSkin
|
||||
skin = ReplSkin("musescore", version="1.0.0")
|
||||
skin.print_banner()
|
||||
|
||||
pt_session = skin.create_prompt_session()
|
||||
|
||||
while True:
|
||||
try:
|
||||
sess = get_session()
|
||||
proj_name = ""
|
||||
modified = False
|
||||
if sess.has_project():
|
||||
proj = sess.get_project()
|
||||
proj_name = proj.get("name", "")
|
||||
modified = sess.is_modified()
|
||||
|
||||
line = skin.get_input(pt_session, project_name=proj_name,
|
||||
modified=modified)
|
||||
if not line:
|
||||
continue
|
||||
if line.lower() in ("quit", "exit", "q"):
|
||||
skin.print_goodbye()
|
||||
break
|
||||
if line.lower() == "help":
|
||||
_repl_help(skin)
|
||||
continue
|
||||
|
||||
import shlex
|
||||
args = shlex.split(line)
|
||||
# Preserve --json flag across REPL commands
|
||||
if _json_output and "--json" not in args:
|
||||
args = ["--json"] + args
|
||||
try:
|
||||
cli.main(args, standalone_mode=False)
|
||||
except SystemExit:
|
||||
pass
|
||||
except click.exceptions.UsageError as e:
|
||||
skin.error(f"Usage error: {e}")
|
||||
except Exception as e:
|
||||
skin.error(str(e))
|
||||
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
skin.print_goodbye()
|
||||
break
|
||||
|
||||
_repl_mode = False
|
||||
|
||||
|
||||
def _repl_help(skin=None):
|
||||
commands = {
|
||||
"project open|info|save": "Project management",
|
||||
"transpose by-key|by-interval|diatonic": "Transposition",
|
||||
"parts list|extract|generate": "Part extraction",
|
||||
"export pdf|png|svg|mp3|wav|midi|...": "Export/render",
|
||||
"instruments list|add|remove|reorder": "Instrument management",
|
||||
"media probe|diff|stats": "Score analysis",
|
||||
"session status|undo|redo|history": "Session management",
|
||||
"help": "Show this help",
|
||||
"quit": "Exit REPL",
|
||||
}
|
||||
if skin is not None:
|
||||
skin.help(commands)
|
||||
else:
|
||||
click.echo("\nCommands:")
|
||||
for cmd, desc in commands.items():
|
||||
click.echo(f" {cmd:50s} {desc}")
|
||||
click.echo()
|
||||
|
||||
|
||||
# ── Entry Point ───────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
cli()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
name: musescore
|
||||
display_name: MuseScore
|
||||
version: 1.0.0
|
||||
description: CLI for music notation — transpose, export PDF/audio/MIDI, extract parts, manage instruments
|
||||
requires: MuseScore 4 (musescore.org)
|
||||
entry_point: cli-anything-musescore
|
||||
category: music
|
||||
---
|
||||
|
||||
# MuseScore CLI Skill
|
||||
|
||||
## Overview
|
||||
|
||||
Wraps MuseScore 4's `mscore` backend for music notation tasks: transposition, export to multiple formats (PDF, PNG, MP3, MIDI, MusicXML, Braille), part extraction, instrument management, and score analysis.
|
||||
|
||||
## Commands
|
||||
|
||||
### Project Management
|
||||
```bash
|
||||
cli-anything-musescore --json project info -i score.mscz
|
||||
cli-anything-musescore --json project open -i score.mscz
|
||||
```
|
||||
|
||||
### Transposition
|
||||
```bash
|
||||
# Transpose to a target key
|
||||
cli-anything-musescore --json transpose by-key -i score.mscz -o out.mscz --target-key "C major" --direction closest
|
||||
|
||||
# Transpose by semitones
|
||||
cli-anything-musescore --json transpose by-interval -i score.mscz -o out.mscz --semitones 3
|
||||
|
||||
# Diatonic transposition
|
||||
cli-anything-musescore --json transpose diatonic -i score.mscz -o out.mscz --steps 2
|
||||
```
|
||||
|
||||
### Part Extraction
|
||||
```bash
|
||||
cli-anything-musescore --json parts list -i score.mscz
|
||||
cli-anything-musescore --json parts extract -i score.mscz -o piano.mscz --part "Piano"
|
||||
cli-anything-musescore --json parts generate -i score.mscz -d ./parts/
|
||||
```
|
||||
|
||||
### Export
|
||||
```bash
|
||||
cli-anything-musescore --json export pdf -i score.mscz -o score.pdf
|
||||
cli-anything-musescore --json export mp3 -i score.mscz -o score.mp3 --bitrate 192
|
||||
cli-anything-musescore --json export png -i score.mscz -o score.png --dpi 300
|
||||
cli-anything-musescore --json export midi -i score.mscz -o score.mid
|
||||
cli-anything-musescore --json export musicxml -i score.mscz -o score.musicxml
|
||||
cli-anything-musescore --json export braille -i score.mscz -o score.brf
|
||||
cli-anything-musescore --json export batch -i score.mscz -o score.pdf -o score.mid
|
||||
```
|
||||
|
||||
### Instrument Management
|
||||
```bash
|
||||
cli-anything-musescore --json instruments list -i score.mscz
|
||||
cli-anything-musescore --json instruments add -i score.mscz -o out.mscz --id keyboard.piano --name "Piano"
|
||||
cli-anything-musescore --json instruments remove -i score.mscz -o out.mscz --name "Violin"
|
||||
```
|
||||
|
||||
### Score Analysis
|
||||
```bash
|
||||
cli-anything-musescore --json media probe -i score.mscz
|
||||
cli-anything-musescore --json media stats -i score.mscz
|
||||
cli-anything-musescore --json media diff --reference a.mscz --compare b.mscz
|
||||
```
|
||||
|
||||
### Session
|
||||
```bash
|
||||
cli-anything-musescore --json session status
|
||||
cli-anything-musescore --json session undo
|
||||
cli-anything-musescore --json session redo
|
||||
cli-anything-musescore --json session history
|
||||
```
|
||||
|
||||
## Supported Input Formats
|
||||
- `.mscz` (MuseScore native)
|
||||
- `.mxl` (compressed MusicXML)
|
||||
- `.musicxml` / `.xml` (MusicXML)
|
||||
- `.mid` / `.midi` (MIDI)
|
||||
|
||||
## Key Names for Transposition
|
||||
Major: Cb, Gb, Db, Ab, Eb, Bb, F, C, G, D, A, E, B, F#, C#
|
||||
Minor: Ab, Eb, Bb, F, C, G, D, A, E, B, F#, C#, G#, D#, A#
|
||||
|
||||
Accepted formats: "C", "C major", "Am", "A minor", "Db major", "F# minor"
|
||||
|
||||
## Agent Guidance
|
||||
- Always use `--json` flag for machine-readable output
|
||||
- Verify exports with `export verify` after rendering
|
||||
- Use `media probe` to inspect an unknown score before operating on it
|
||||
- Transposition requires both `-i` (input) and `-o` (output)
|
||||
- Part names are case-insensitive for `parts extract`
|
||||
@@ -0,0 +1,130 @@
|
||||
# TEST.md — Test Plan and Results
|
||||
|
||||
## Test Plan
|
||||
|
||||
### Unit Tests (`test_core.py`)
|
||||
|
||||
| # | Test | Module | Description |
|
||||
|---|------|--------|-------------|
|
||||
| 1 | TestSession::test_create_session | session | Create empty session |
|
||||
| 2 | TestSession::test_set_project | session | Set project data and path |
|
||||
| 3 | TestSession::test_get_project_raises | session | Error when no project open |
|
||||
| 4 | TestSession::test_undo_redo | session | Undo/redo state transitions |
|
||||
| 5 | TestSession::test_undo_empty_raises | session | Error on empty undo |
|
||||
| 6 | TestSession::test_redo_empty_raises | session | Error on empty redo |
|
||||
| 7 | TestSession::test_snapshot_clears_redo | session | New edit clears redo |
|
||||
| 8 | TestSession::test_history | session | History tracking |
|
||||
| 9 | TestSession::test_status | session | Status dict format |
|
||||
| 10 | TestSession::test_modified_flag | session | Modified flag tracking |
|
||||
| 11 | TestSession::test_save_session | session | JSON persistence with locking |
|
||||
| 12 | TestKeySignature::test_major_keys | mscx_xml | Key name → int (majors) |
|
||||
| 13 | TestKeySignature::test_minor_keys | mscx_xml | Key name → int (minors) |
|
||||
| 14 | TestKeySignature::test_case_insensitive | mscx_xml | Case-insensitive lookup |
|
||||
| 15 | TestKeySignature::test_invalid_key | mscx_xml | Error on invalid key |
|
||||
| 16 | TestKeySignature::test_int_to_name | mscx_xml | Int → key name |
|
||||
| 17 | TestKeySignature::test_all_major_keys_roundtrip | mscx_xml | Full roundtrip |
|
||||
| 18 | TestTranspose::test_semitones_* | transpose | Semitone → interval mapping |
|
||||
| 19 | TestXMLParsing::test_get_key_signature | mscx_xml | XML key sig extraction |
|
||||
| 20 | TestXMLParsing::test_get_time_signature | mscx_xml | XML time sig extraction |
|
||||
| 21 | TestXMLParsing::test_get_instruments | mscx_xml | XML instrument extraction |
|
||||
| 22 | TestXMLParsing::test_get_score_title | mscx_xml | XML title extraction |
|
||||
| 23 | TestXMLParsing::test_count_measures | mscx_xml | Measure counting |
|
||||
| 24 | TestXMLParsing::test_count_notes | mscx_xml | Note counting |
|
||||
| 25 | TestXMLParsing::test_detect_format | mscx_xml | Format detection |
|
||||
| 26 | TestXMLParsing::test_mscz_roundtrip | mscx_xml | MSCZ write + read |
|
||||
| 27 | TestExportVerification::test_* | export | Magic byte verification |
|
||||
| 28 | TestMediaStats::test_score_stats | media | Synthetic MXL stats |
|
||||
|
||||
### E2E Tests (`test_full_e2e.py`)
|
||||
|
||||
| # | Test | Requires | Description |
|
||||
|---|------|----------|-------------|
|
||||
| 1 | TestExportE2E::test_export_pdf | mscore + samples | Export PDF, verify magic |
|
||||
| 2 | TestExportE2E::test_export_midi | mscore + samples | Export MIDI, verify magic |
|
||||
| 3 | TestExportE2E::test_export_mp3 | mscore + samples | Export MP3, verify magic |
|
||||
| 4 | TestExportE2E::test_export_musicxml | mscore + samples | Export MusicXML, verify XML |
|
||||
| 5 | TestExportE2E::test_export_png | mscore + samples | Export PNG pages |
|
||||
| 6 | TestTransposeE2E::test_transpose_db_to_c | mscore + samples | Db→C key verification |
|
||||
| 7 | TestTransposeE2E::test_transpose_by_interval | mscore + samples | Interval transpose |
|
||||
| 8 | TestPartsE2E::test_list_parts | mscore + samples | Part listing |
|
||||
| 9 | TestPartsE2E::test_extract_part | mscore + samples | Part extraction |
|
||||
| 10 | TestMediaE2E::test_probe_mxl | mscore + samples | MXL metadata probe |
|
||||
| 11 | TestMediaE2E::test_probe_mscz | mscore + samples | MSCZ metadata probe |
|
||||
| 12 | TestMediaE2E::test_stats_mxl | mscore + samples | Score statistics |
|
||||
| 13 | TestCLISubprocess::test_help | none | --help flag |
|
||||
| 14 | TestCLISubprocess::test_json_project_info | mscore + samples | Subprocess JSON output |
|
||||
| 15 | TestCLISubprocess::test_json_export_pdf | mscore + samples | Subprocess PDF export |
|
||||
| 16 | TestCLISubprocess::test_json_transpose_by_key | mscore + samples | Subprocess transpose |
|
||||
| 17 | TestCLISubprocess::test_full_workflow | mscore + samples | Info→transpose→export→verify |
|
||||
|
||||
## Test Results
|
||||
|
||||
```
|
||||
$ python3 -m pytest cli_anything/musescore/tests/ -v --tb=short
|
||||
============================= test session starts ==============================
|
||||
platform darwin -- Python 3.12.2, pytest-7.4.4
|
||||
rootdir: /Users/vickytam/Study/cli-anything/musescore/agent-harness
|
||||
|
||||
cli_anything/musescore/tests/test_core.py::TestSession::test_create_session PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestSession::test_set_project PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestSession::test_get_project_raises_without_open PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestSession::test_undo_redo PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestSession::test_undo_empty_raises PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestSession::test_redo_empty_raises PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestSession::test_snapshot_clears_redo PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestSession::test_history PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestSession::test_status PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestSession::test_modified_flag PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestSession::test_save_session PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestKeySignature::test_major_keys PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestKeySignature::test_minor_keys PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestKeySignature::test_case_insensitive PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestKeySignature::test_invalid_key PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestKeySignature::test_int_to_name PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestKeySignature::test_all_major_keys_roundtrip PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestTranspose::test_semitones_to_interval_unison PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestTranspose::test_semitones_to_interval_minor_second PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestTranspose::test_semitones_to_interval_octave PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestTranspose::test_semitones_to_interval_fifth PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestTranspose::test_interval_enum_count PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestXMLParsing::test_get_key_signature PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestXMLParsing::test_get_time_signature PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestXMLParsing::test_get_instruments PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestXMLParsing::test_get_score_title PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestXMLParsing::test_count_measures PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestXMLParsing::test_count_notes PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestXMLParsing::test_detect_format PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestXMLParsing::test_mscz_roundtrip PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestExportVerification::test_ext_to_format PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestExportVerification::test_verify_nonexistent PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestExportVerification::test_verify_pdf PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestExportVerification::test_verify_midi PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestExportVerification::test_verify_mp3_sync PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestExportVerification::test_verify_mp3_id3 PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestExportVerification::test_verify_png PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestExportVerification::test_verify_empty_file PASSED
|
||||
cli_anything/musescore/tests/test_core.py::TestMediaStats::test_score_stats_from_mxl PASSED
|
||||
cli_anything/musescore/tests/test_full_e2e.py::TestExportE2E::test_export_pdf PASSED
|
||||
cli_anything/musescore/tests/test_full_e2e.py::TestExportE2E::test_export_midi PASSED
|
||||
cli_anything/musescore/tests/test_full_e2e.py::TestExportE2E::test_export_mp3 PASSED
|
||||
cli_anything/musescore/tests/test_full_e2e.py::TestExportE2E::test_export_musicxml PASSED
|
||||
cli_anything/musescore/tests/test_full_e2e.py::TestExportE2E::test_export_png PASSED
|
||||
cli_anything/musescore/tests/test_full_e2e.py::TestTransposeE2E::test_transpose_db_to_c PASSED
|
||||
cli_anything/musescore/tests/test_full_e2e.py::TestTransposeE2E::test_transpose_by_interval PASSED
|
||||
cli_anything/musescore/tests/test_full_e2e.py::TestPartsE2E::test_list_parts PASSED
|
||||
cli_anything/musescore/tests/test_full_e2e.py::TestPartsE2E::test_extract_part PASSED
|
||||
cli_anything/musescore/tests/test_full_e2e.py::TestMediaE2E::test_probe_mxl PASSED
|
||||
cli_anything/musescore/tests/test_full_e2e.py::TestMediaE2E::test_probe_mscz PASSED
|
||||
cli_anything/musescore/tests/test_full_e2e.py::TestMediaE2E::test_stats_mxl PASSED
|
||||
cli_anything/musescore/tests/test_full_e2e.py::TestCLISubprocess::test_help PASSED
|
||||
cli_anything/musescore/tests/test_full_e2e.py::TestCLISubprocess::test_json_project_info PASSED
|
||||
cli_anything/musescore/tests/test_full_e2e.py::TestCLISubprocess::test_json_export_pdf PASSED
|
||||
cli_anything/musescore/tests/test_full_e2e.py::TestCLISubprocess::test_json_transpose_by_key PASSED
|
||||
cli_anything/musescore/tests/test_full_e2e.py::TestCLISubprocess::test_full_workflow PASSED
|
||||
|
||||
============================= 56 passed in 15.10s ==============================
|
||||
```
|
||||
|
||||
**Environment**: macOS Darwin 24.1.0, Python 3.12.2, MuseScore 4.6.5
|
||||
**Date**: 2026-03-19
|
||||
**Status**: 56/56 PASSED (39 unit + 17 E2E)
|
||||
musescore/agent-harness/cli_anything/musescore/tests/fixtures/twinkle-twinkle/twinkle_twinkle_G.mscz
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,398 @@
|
||||
"""Unit tests for cli-anything-musescore core modules.
|
||||
|
||||
These tests use synthetic data and do NOT require mscore to be installed.
|
||||
They test key name resolution, session management, XML parsing, etc.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# ── Session Tests ─────────────────────────────────────────────────────
|
||||
|
||||
from cli_anything.musescore.core.session import Session
|
||||
|
||||
|
||||
class TestSession:
|
||||
def test_create_session(self):
|
||||
s = Session()
|
||||
assert not s.has_project()
|
||||
assert s.project_data is None
|
||||
|
||||
def test_set_project(self):
|
||||
s = Session()
|
||||
s.set_project({"name": "test"}, "/tmp/test.mscz")
|
||||
assert s.has_project()
|
||||
assert s.project_data["name"] == "test"
|
||||
assert s.project_path == "/tmp/test.mscz"
|
||||
|
||||
def test_get_project_raises_without_open(self):
|
||||
s = Session()
|
||||
with pytest.raises(RuntimeError, match="No project"):
|
||||
s.get_project()
|
||||
|
||||
def test_undo_redo(self):
|
||||
s = Session()
|
||||
s.set_project({"name": "v1"})
|
||||
s.snapshot("edit 1")
|
||||
s.project_data["name"] = "v2"
|
||||
s.snapshot("edit 2")
|
||||
s.project_data["name"] = "v3"
|
||||
|
||||
# Undo edit 2
|
||||
desc = s.undo()
|
||||
assert desc == "edit 2"
|
||||
assert s.project_data["name"] == "v2"
|
||||
|
||||
# Undo edit 1
|
||||
desc = s.undo()
|
||||
assert desc == "edit 1"
|
||||
assert s.project_data["name"] == "v1"
|
||||
|
||||
# Redo edit 1
|
||||
desc = s.redo()
|
||||
assert desc == "edit 1"
|
||||
assert s.project_data["name"] == "v2"
|
||||
|
||||
def test_undo_empty_raises(self):
|
||||
s = Session()
|
||||
s.set_project({"name": "test"})
|
||||
with pytest.raises(RuntimeError, match="Nothing to undo"):
|
||||
s.undo()
|
||||
|
||||
def test_redo_empty_raises(self):
|
||||
s = Session()
|
||||
s.set_project({"name": "test"})
|
||||
with pytest.raises(RuntimeError, match="Nothing to redo"):
|
||||
s.redo()
|
||||
|
||||
def test_snapshot_clears_redo(self):
|
||||
s = Session()
|
||||
s.set_project({"name": "v1"})
|
||||
s.snapshot("edit 1")
|
||||
s.project_data["name"] = "v2"
|
||||
s.undo()
|
||||
# New edit should clear redo stack
|
||||
s.snapshot("edit 2")
|
||||
assert len(s.redo_stack) == 0
|
||||
|
||||
def test_history(self):
|
||||
s = Session()
|
||||
s.set_project({"name": "test"})
|
||||
s.snapshot("action 1")
|
||||
s.snapshot("action 2")
|
||||
s.snapshot("action 3")
|
||||
assert s.list_history() == ["action 1", "action 2", "action 3"]
|
||||
|
||||
def test_status(self):
|
||||
s = Session()
|
||||
s.set_project({"name": "test"}, "/tmp/test.mscz")
|
||||
s.snapshot("edit")
|
||||
status = s.status()
|
||||
assert status["project_path"] == "/tmp/test.mscz"
|
||||
assert status["undo_depth"] == 1
|
||||
assert status["redo_depth"] == 0
|
||||
|
||||
def test_modified_flag(self):
|
||||
s = Session()
|
||||
s.set_project({"name": "test"})
|
||||
assert not s.is_modified()
|
||||
s.snapshot("edit")
|
||||
assert s.is_modified()
|
||||
|
||||
def test_save_session(self):
|
||||
s = Session()
|
||||
s.set_project({"name": "test"}, "/tmp/test.mscz")
|
||||
s.snapshot("edit")
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = os.path.join(tmpdir, "test.mscz")
|
||||
result = s.save_session(path)
|
||||
assert os.path.isfile(result)
|
||||
with open(result) as f:
|
||||
data = json.load(f)
|
||||
assert data["history"] == ["edit"]
|
||||
|
||||
|
||||
# ── Key Signature Tests ───────────────────────────────────────────────
|
||||
|
||||
from cli_anything.musescore.utils.mscx_xml import (
|
||||
key_name_to_int, key_int_to_name, KEY_INT_TO_MAJOR,
|
||||
)
|
||||
|
||||
|
||||
class TestKeySignature:
|
||||
def test_major_keys(self):
|
||||
assert key_name_to_int("C") == 0
|
||||
assert key_name_to_int("C major") == 0
|
||||
assert key_name_to_int("G") == 1
|
||||
assert key_name_to_int("Db") == -5
|
||||
assert key_name_to_int("Db major") == -5
|
||||
assert key_name_to_int("F#") == 6
|
||||
|
||||
def test_minor_keys(self):
|
||||
assert key_name_to_int("A minor") == 0
|
||||
assert key_name_to_int("Am") == 0
|
||||
assert key_name_to_int("D minor") == -1
|
||||
assert key_name_to_int("F# minor") == 3
|
||||
|
||||
def test_case_insensitive(self):
|
||||
assert key_name_to_int("c major") == 0
|
||||
assert key_name_to_int("DB MAJOR") == -5
|
||||
assert key_name_to_int("am") == 0
|
||||
|
||||
def test_invalid_key(self):
|
||||
with pytest.raises(ValueError, match="Unrecognized key"):
|
||||
key_name_to_int("X major")
|
||||
|
||||
def test_int_to_name(self):
|
||||
assert key_int_to_name(0) == "C major"
|
||||
assert key_int_to_name(-5) == "Db major"
|
||||
assert key_int_to_name(0, minor=True) == "A minor"
|
||||
|
||||
def test_all_major_keys_roundtrip(self):
|
||||
for i, name in KEY_INT_TO_MAJOR.items():
|
||||
assert key_name_to_int(name) == i
|
||||
assert key_name_to_int(f"{name} major") == i
|
||||
|
||||
|
||||
# ── Transpose Option Building Tests ───────────────────────────────────
|
||||
|
||||
from cli_anything.musescore.core.transpose import (
|
||||
semitones_to_interval_index, INTERVAL_ENUM,
|
||||
)
|
||||
|
||||
|
||||
class TestTranspose:
|
||||
def test_semitones_to_interval_unison(self):
|
||||
assert semitones_to_interval_index(0) == 0 # Perfect Unison
|
||||
|
||||
def test_semitones_to_interval_minor_second(self):
|
||||
assert semitones_to_interval_index(1) == 1 # Minor Second
|
||||
|
||||
def test_semitones_to_interval_octave(self):
|
||||
assert semitones_to_interval_index(12) == 12 # Perfect Octave
|
||||
|
||||
def test_semitones_to_interval_fifth(self):
|
||||
assert semitones_to_interval_index(7) == 7 # Perfect Fifth
|
||||
|
||||
def test_interval_enum_count(self):
|
||||
assert len(INTERVAL_ENUM) == 26
|
||||
|
||||
|
||||
# ── XML Parsing Tests ─────────────────────────────────────────────────
|
||||
|
||||
from cli_anything.musescore.utils.mscx_xml import (
|
||||
get_key_signature, get_time_signature, get_instruments,
|
||||
get_score_title, count_measures, count_notes,
|
||||
detect_format, read_mscz, write_mscz,
|
||||
)
|
||||
|
||||
|
||||
class TestXMLParsing:
|
||||
def _make_musicxml(self, fifths=-5, beats="4", beat_type="4",
|
||||
title="Test Score", num_measures=4, num_notes=16):
|
||||
"""Create a synthetic MusicXML tree for testing."""
|
||||
root = ET.Element("score-partwise", version="4.0")
|
||||
# Work title
|
||||
work = ET.SubElement(root, "work")
|
||||
ET.SubElement(work, "work-title").text = title
|
||||
# Part list
|
||||
part_list = ET.SubElement(root, "part-list")
|
||||
sp = ET.SubElement(part_list, "score-part", id="P1")
|
||||
ET.SubElement(sp, "part-name").text = "Piano"
|
||||
si = ET.SubElement(sp, "score-instrument", id="P1-I1")
|
||||
ET.SubElement(si, "instrument-name").text = "Piano"
|
||||
# Part with measures
|
||||
part = ET.SubElement(root, "part", id="P1")
|
||||
for m in range(num_measures):
|
||||
measure = ET.SubElement(part, "measure", number=str(m + 1))
|
||||
if m == 0:
|
||||
attrs = ET.SubElement(measure, "attributes")
|
||||
key = ET.SubElement(attrs, "key")
|
||||
ET.SubElement(key, "fifths").text = str(fifths)
|
||||
time = ET.SubElement(attrs, "time")
|
||||
ET.SubElement(time, "beats").text = beats
|
||||
ET.SubElement(time, "beat-type").text = beat_type
|
||||
for n in range(num_notes // num_measures):
|
||||
note = ET.SubElement(measure, "note")
|
||||
pitch = ET.SubElement(note, "pitch")
|
||||
ET.SubElement(pitch, "step").text = "C"
|
||||
ET.SubElement(pitch, "octave").text = "4"
|
||||
return ET.ElementTree(root)
|
||||
|
||||
def test_get_key_signature(self):
|
||||
tree = self._make_musicxml(fifths=-5)
|
||||
assert get_key_signature(tree) == -5
|
||||
|
||||
def test_get_time_signature(self):
|
||||
tree = self._make_musicxml(beats="3", beat_type="4")
|
||||
assert get_time_signature(tree) == "3/4"
|
||||
|
||||
def test_get_instruments(self):
|
||||
tree = self._make_musicxml()
|
||||
instruments = get_instruments(tree)
|
||||
assert len(instruments) == 1
|
||||
assert instruments[0]["name"] == "Piano"
|
||||
|
||||
def test_get_score_title(self):
|
||||
tree = self._make_musicxml(title="My Score")
|
||||
assert get_score_title(tree) == "My Score"
|
||||
|
||||
def test_count_measures(self):
|
||||
tree = self._make_musicxml(num_measures=8)
|
||||
assert count_measures(tree) == 8
|
||||
|
||||
def test_count_notes(self):
|
||||
tree = self._make_musicxml(num_measures=4, num_notes=16)
|
||||
assert count_notes(tree) == 16
|
||||
|
||||
def test_detect_format(self):
|
||||
assert detect_format("score.mscz") == "mscz"
|
||||
assert detect_format("score.mxl") == "mxl"
|
||||
assert detect_format("score.musicxml") == "musicxml"
|
||||
assert detect_format("score.mid") == "mid"
|
||||
assert detect_format("score.txt") == "unknown"
|
||||
|
||||
def test_mscz_roundtrip(self):
|
||||
"""Test writing and reading a .mscz file."""
|
||||
tree = self._make_musicxml()
|
||||
data = {
|
||||
"mscx": tree,
|
||||
"mscx_filename": "score.mscx",
|
||||
"style": "<Style></Style>",
|
||||
"audio_settings": '{"master_gain": 1.0}',
|
||||
"view_settings": '{"zoom": 100}',
|
||||
"other_files": {},
|
||||
}
|
||||
with tempfile.NamedTemporaryFile(suffix=".mscz", delete=False) as f:
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
write_mscz(tmp_path, data)
|
||||
assert os.path.isfile(tmp_path)
|
||||
|
||||
# Verify it's a valid ZIP
|
||||
assert zipfile.is_zipfile(tmp_path)
|
||||
|
||||
# Read back
|
||||
read_data = read_mscz(tmp_path)
|
||||
assert read_data["mscx"] is not None
|
||||
assert read_data["style"] == "<Style></Style>"
|
||||
assert get_key_signature(read_data["mscx"]) == -5
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
# ── Export Verification Tests ─────────────────────────────────────────
|
||||
|
||||
from cli_anything.musescore.core.export import verify_output, _ext_to_format
|
||||
|
||||
|
||||
class TestExportVerification:
|
||||
def test_ext_to_format(self):
|
||||
assert _ext_to_format(".pdf") == "pdf"
|
||||
assert _ext_to_format(".mid") == "midi"
|
||||
assert _ext_to_format(".mp3") == "mp3"
|
||||
assert _ext_to_format(".musicxml") == "musicxml"
|
||||
|
||||
def test_verify_nonexistent(self):
|
||||
result = verify_output("/nonexistent/file.pdf")
|
||||
assert not result["exists"]
|
||||
assert not result["valid"]
|
||||
|
||||
def test_verify_pdf(self):
|
||||
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
|
||||
f.write(b"%PDF-1.4 test content")
|
||||
tmp = f.name
|
||||
try:
|
||||
result = verify_output(tmp, "pdf")
|
||||
assert result["exists"]
|
||||
assert result["valid"]
|
||||
finally:
|
||||
os.unlink(tmp)
|
||||
|
||||
def test_verify_midi(self):
|
||||
with tempfile.NamedTemporaryFile(suffix=".mid", delete=False) as f:
|
||||
f.write(b"MThd\x00\x00\x00\x06")
|
||||
tmp = f.name
|
||||
try:
|
||||
result = verify_output(tmp, "midi")
|
||||
assert result["exists"]
|
||||
assert result["valid"]
|
||||
finally:
|
||||
os.unlink(tmp)
|
||||
|
||||
def test_verify_mp3_sync(self):
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
|
||||
f.write(b"\xff\xfb\x90\x00" + b"\x00" * 100)
|
||||
tmp = f.name
|
||||
try:
|
||||
result = verify_output(tmp, "mp3")
|
||||
assert result["valid"]
|
||||
finally:
|
||||
os.unlink(tmp)
|
||||
|
||||
def test_verify_mp3_id3(self):
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
|
||||
f.write(b"ID3" + b"\x00" * 100)
|
||||
tmp = f.name
|
||||
try:
|
||||
result = verify_output(tmp, "mp3")
|
||||
assert result["valid"]
|
||||
finally:
|
||||
os.unlink(tmp)
|
||||
|
||||
def test_verify_png(self):
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
|
||||
f.write(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
|
||||
tmp = f.name
|
||||
try:
|
||||
result = verify_output(tmp, "png")
|
||||
assert result["valid"]
|
||||
finally:
|
||||
os.unlink(tmp)
|
||||
|
||||
def test_verify_empty_file(self):
|
||||
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
|
||||
tmp = f.name
|
||||
try:
|
||||
result = verify_output(tmp, "pdf")
|
||||
assert result["exists"]
|
||||
assert not result["valid"]
|
||||
finally:
|
||||
os.unlink(tmp)
|
||||
|
||||
|
||||
# ── Media Stats Tests (synthetic) ─────────────────────────────────────
|
||||
|
||||
class TestMediaStats:
|
||||
def test_score_stats_from_mxl(self):
|
||||
"""Create a synthetic .mxl and test stats extraction."""
|
||||
tree = TestXMLParsing()._make_musicxml(
|
||||
fifths=0, num_measures=8, num_notes=32, title="Stats Test"
|
||||
)
|
||||
xml_str = ET.tostring(tree.getroot(), encoding="unicode",
|
||||
xml_declaration=True)
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".mxl", delete=False) as f:
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(tmp_path, "w") as zf:
|
||||
zf.writestr("score.xml", xml_str)
|
||||
|
||||
from cli_anything.musescore.core.media import score_stats
|
||||
result = score_stats(tmp_path)
|
||||
assert result["format"] == "mxl"
|
||||
assert result["stats"]["measures"] == 8
|
||||
assert result["stats"]["notes"] == 32
|
||||
assert result["stats"]["title"] == "Stats Test"
|
||||
assert result["stats"]["key_signature"] == 0
|
||||
assert result["stats"]["key_name"] == "C major"
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
@@ -0,0 +1,362 @@
|
||||
"""End-to-end tests for cli-anything-musescore.
|
||||
|
||||
These tests require a real MuseScore 4 (mscore) installation and
|
||||
use the sample files in musescore/test-mscore/twinkle-twinkle/.
|
||||
|
||||
Run with: pytest cli_anything/musescore/tests/test_full_e2e.py -v -s
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# ── Test fixture paths ────────────────────────────────────────────────
|
||||
|
||||
# Test fixtures live alongside this file
|
||||
_THIS_DIR = Path(__file__).resolve().parent
|
||||
_SAMPLE_DIR = _THIS_DIR / "fixtures" / "twinkle-twinkle"
|
||||
_SAMPLE_MXL = _SAMPLE_DIR / "twinkle_twinkle_G.mxl"
|
||||
_SAMPLE_MSCZ = _SAMPLE_DIR / "twinkle_twinkle_G.mscz"
|
||||
|
||||
|
||||
def _resolve_cli(name: str) -> list[str]:
|
||||
"""Resolve the CLI command for subprocess tests.
|
||||
|
||||
If CLI_ANYTHING_FORCE_INSTALLED is set, use the installed command.
|
||||
Otherwise, use python -m.
|
||||
"""
|
||||
if os.environ.get("CLI_ANYTHING_FORCE_INSTALLED"):
|
||||
import shutil
|
||||
path = shutil.which(name)
|
||||
if path:
|
||||
return [path]
|
||||
raise RuntimeError(f"{name} not found on PATH")
|
||||
return [sys.executable, "-m", "cli_anything.musescore"]
|
||||
|
||||
|
||||
def _has_mscore() -> bool:
|
||||
"""Check if mscore is available."""
|
||||
try:
|
||||
from cli_anything.musescore.utils.musescore_backend import find_musescore
|
||||
find_musescore()
|
||||
return True
|
||||
except RuntimeError:
|
||||
return False
|
||||
|
||||
|
||||
def _has_samples() -> bool:
|
||||
"""Check if sample files exist."""
|
||||
return _SAMPLE_MXL.is_file()
|
||||
|
||||
|
||||
requires_mscore = pytest.mark.skipif(
|
||||
not _has_mscore(), reason="mscore not installed"
|
||||
)
|
||||
requires_samples = pytest.mark.skipif(
|
||||
not _has_samples(), reason="sample files not found"
|
||||
)
|
||||
|
||||
|
||||
# ── Export E2E Tests ──────────────────────────────────────────────────
|
||||
|
||||
@requires_mscore
|
||||
@requires_samples
|
||||
class TestExportE2E:
|
||||
def test_export_pdf(self):
|
||||
"""Export MXL to PDF, verify magic bytes."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
|
||||
out = f.name
|
||||
try:
|
||||
from cli_anything.musescore.core.export import export_score, verify_output
|
||||
export_score(str(_SAMPLE_MXL), out, fmt="pdf")
|
||||
result = verify_output(out, "pdf")
|
||||
assert result["valid"], f"PDF verification failed: {result}"
|
||||
print(f" PDF output: {out} ({result['size']} bytes)")
|
||||
finally:
|
||||
if os.path.exists(out):
|
||||
os.unlink(out)
|
||||
|
||||
def test_export_midi(self):
|
||||
"""Export MXL to MIDI, verify magic bytes."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".mid", delete=False) as f:
|
||||
out = f.name
|
||||
try:
|
||||
from cli_anything.musescore.core.export import export_score, verify_output
|
||||
export_score(str(_SAMPLE_MXL), out, fmt="midi")
|
||||
result = verify_output(out, "midi")
|
||||
assert result["valid"], f"MIDI verification failed: {result}"
|
||||
print(f" MIDI output: {out} ({result['size']} bytes)")
|
||||
finally:
|
||||
if os.path.exists(out):
|
||||
os.unlink(out)
|
||||
|
||||
def test_export_mp3(self):
|
||||
"""Export MXL to MP3, verify magic bytes."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
|
||||
out = f.name
|
||||
try:
|
||||
from cli_anything.musescore.core.export import export_score, verify_output
|
||||
export_score(str(_SAMPLE_MXL), out, fmt="mp3", bitrate=128)
|
||||
result = verify_output(out, "mp3")
|
||||
assert result["valid"], f"MP3 verification failed: {result}"
|
||||
print(f" MP3 output: {out} ({result['size']} bytes)")
|
||||
finally:
|
||||
if os.path.exists(out):
|
||||
os.unlink(out)
|
||||
|
||||
def test_export_musicxml(self):
|
||||
"""Export MSCZ to MusicXML, verify XML structure."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".musicxml", delete=False) as f:
|
||||
out = f.name
|
||||
try:
|
||||
from cli_anything.musescore.core.export import export_score
|
||||
export_score(str(_SAMPLE_MSCZ), out, fmt="musicxml")
|
||||
import xml.etree.ElementTree as ET
|
||||
tree = ET.parse(out)
|
||||
assert tree.getroot().tag == "score-partwise"
|
||||
print(f" MusicXML output: {out}")
|
||||
finally:
|
||||
if os.path.exists(out):
|
||||
os.unlink(out)
|
||||
|
||||
def test_export_png(self):
|
||||
"""Export MXL to PNG, verify at least one page produced."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
out = os.path.join(tmpdir, "output.png")
|
||||
from cli_anything.musescore.core.export import export_score
|
||||
export_score(str(_SAMPLE_MXL), out, fmt="png", dpi=72)
|
||||
# mscore produces output-1.png, output-2.png, etc.
|
||||
pngs = list(Path(tmpdir).glob("*.png"))
|
||||
assert len(pngs) >= 1, f"No PNG files produced in {tmpdir}"
|
||||
# Verify first PNG magic bytes
|
||||
with open(pngs[0], "rb") as f:
|
||||
header = f.read(4)
|
||||
assert header == b"\x89PNG", f"Invalid PNG header: {header}"
|
||||
print(f" PNG pages: {len(pngs)}")
|
||||
|
||||
|
||||
# ── Transpose E2E Tests ──────────────────────────────────────────────
|
||||
|
||||
@requires_mscore
|
||||
@requires_samples
|
||||
class TestTransposeE2E:
|
||||
def test_transpose_g_to_c(self):
|
||||
"""Transpose G major MXL to C major, verify key signature."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".mscz", delete=False) as f:
|
||||
out = f.name
|
||||
try:
|
||||
from cli_anything.musescore.core.transpose import transpose_by_key
|
||||
result = transpose_by_key(
|
||||
str(_SAMPLE_MXL), out,
|
||||
target_key="C major",
|
||||
direction="closest",
|
||||
)
|
||||
assert result["target_key_int"] == 0
|
||||
|
||||
# Export to MusicXML and check key signature
|
||||
with tempfile.NamedTemporaryFile(suffix=".musicxml", delete=False) as fx:
|
||||
xml_out = fx.name
|
||||
from cli_anything.musescore.core.export import export_score
|
||||
export_score(out, xml_out, fmt="musicxml")
|
||||
|
||||
from cli_anything.musescore.utils.mscx_xml import read_score_tree, get_key_signature
|
||||
tree = read_score_tree(xml_out)
|
||||
keysig = get_key_signature(tree)
|
||||
assert keysig == 0, f"Expected keysig=0 (C major), got {keysig}"
|
||||
print(f" Transposed G→C: keysig={keysig}")
|
||||
os.unlink(xml_out)
|
||||
finally:
|
||||
if os.path.exists(out):
|
||||
os.unlink(out)
|
||||
|
||||
def test_transpose_by_interval(self):
|
||||
"""Transpose by 2 semitones (major second up)."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".mscz", delete=False) as f:
|
||||
out = f.name
|
||||
try:
|
||||
from cli_anything.musescore.core.transpose import transpose_by_interval
|
||||
result = transpose_by_interval(
|
||||
str(_SAMPLE_MXL), out,
|
||||
semitones=2,
|
||||
direction="up",
|
||||
)
|
||||
assert result["mode"] == "by_interval"
|
||||
assert os.path.isfile(out)
|
||||
print(f" Transposed by +2 semitones: {out}")
|
||||
finally:
|
||||
if os.path.exists(out):
|
||||
os.unlink(out)
|
||||
|
||||
|
||||
# ── Parts E2E Tests ──────────────────────────────────────────────────
|
||||
|
||||
@requires_mscore
|
||||
@requires_samples
|
||||
class TestPartsE2E:
|
||||
def test_list_parts(self):
|
||||
"""List parts in the sample score."""
|
||||
from cli_anything.musescore.core.parts import list_parts
|
||||
parts = list_parts(str(_SAMPLE_MXL))
|
||||
assert len(parts) >= 1
|
||||
print(f" Parts: {[p['name'] for p in parts]}")
|
||||
|
||||
def test_extract_part(self):
|
||||
"""Extract the first part."""
|
||||
from cli_anything.musescore.core.parts import list_parts, extract_part
|
||||
parts = list_parts(str(_SAMPLE_MXL))
|
||||
if not parts:
|
||||
pytest.skip("No parts found")
|
||||
|
||||
first_part = parts[0]["name"]
|
||||
with tempfile.NamedTemporaryFile(suffix=".mscz", delete=False) as f:
|
||||
out = f.name
|
||||
try:
|
||||
result = extract_part(str(_SAMPLE_MXL), first_part, out)
|
||||
assert os.path.isfile(out)
|
||||
assert result["size_bytes"] > 0
|
||||
print(f" Extracted '{first_part}': {result['size_bytes']} bytes")
|
||||
finally:
|
||||
if os.path.exists(out):
|
||||
os.unlink(out)
|
||||
|
||||
|
||||
# ── Media E2E Tests ──────────────────────────────────────────────────
|
||||
|
||||
@requires_mscore
|
||||
@requires_samples
|
||||
class TestMediaE2E:
|
||||
def test_probe_mxl(self):
|
||||
"""Probe sample MXL file."""
|
||||
from cli_anything.musescore.core.media import probe_score
|
||||
result = probe_score(str(_SAMPLE_MXL))
|
||||
assert result["format"] == "mxl"
|
||||
assert "metadata" in result
|
||||
print(f" Probe: {json.dumps(result.get('metadata', {}), indent=2, default=str)[:200]}")
|
||||
|
||||
def test_probe_mscz(self):
|
||||
"""Probe sample MSCZ file."""
|
||||
from cli_anything.musescore.core.media import probe_score
|
||||
result = probe_score(str(_SAMPLE_MSCZ))
|
||||
assert result["format"] == "mscz"
|
||||
assert "metadata" in result
|
||||
|
||||
def test_stats_mxl(self):
|
||||
"""Get stats for sample MXL file."""
|
||||
from cli_anything.musescore.core.media import score_stats
|
||||
result = score_stats(str(_SAMPLE_MXL))
|
||||
assert "stats" in result
|
||||
assert result["stats"]["measures"] > 0
|
||||
assert result["stats"]["notes"] > 0
|
||||
print(f" Stats: {result['stats']}")
|
||||
|
||||
|
||||
# ── CLI Subprocess Tests ─────────────────────────────────────────────
|
||||
|
||||
class TestCLISubprocess:
|
||||
def test_help(self):
|
||||
"""Test --help flag."""
|
||||
cmd = _resolve_cli("cli-anything-musescore") + ["--help"]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
assert result.returncode == 0
|
||||
assert "MuseScore CLI" in result.stdout or "musescore" in result.stdout.lower()
|
||||
print(f" --help: OK ({len(result.stdout)} chars)")
|
||||
|
||||
@requires_mscore
|
||||
@requires_samples
|
||||
def test_json_project_info(self):
|
||||
"""Test --json project info via subprocess."""
|
||||
cmd = _resolve_cli("cli-anything-musescore") + [
|
||||
"--json", "project", "info", "-i", str(_SAMPLE_MXL)
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||||
assert result.returncode == 0
|
||||
data = json.loads(result.stdout)
|
||||
assert "metadata" in data or "path" in data
|
||||
print(f" JSON project info: OK")
|
||||
|
||||
@requires_mscore
|
||||
@requires_samples
|
||||
def test_json_export_pdf(self):
|
||||
"""Test --json export pdf via subprocess."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
|
||||
out = f.name
|
||||
try:
|
||||
cmd = _resolve_cli("cli-anything-musescore") + [
|
||||
"--json", "export", "pdf",
|
||||
"-i", str(_SAMPLE_MXL),
|
||||
"-o", out,
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||||
assert result.returncode == 0
|
||||
data = json.loads(result.stdout)
|
||||
assert data.get("format") == "pdf"
|
||||
# Verify the output file
|
||||
assert os.path.isfile(out)
|
||||
with open(out, "rb") as f:
|
||||
header = f.read(5)
|
||||
assert header == b"%PDF-"
|
||||
print(f" JSON export pdf: OK")
|
||||
finally:
|
||||
if os.path.exists(out):
|
||||
os.unlink(out)
|
||||
|
||||
@requires_mscore
|
||||
@requires_samples
|
||||
def test_json_transpose_by_key(self):
|
||||
"""Test --json transpose by-key via subprocess."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".mscz", delete=False) as f:
|
||||
out = f.name
|
||||
try:
|
||||
cmd = _resolve_cli("cli-anything-musescore") + [
|
||||
"--json", "transpose", "by-key",
|
||||
"-i", str(_SAMPLE_MXL),
|
||||
"-o", out,
|
||||
"--target-key", "C major",
|
||||
"--direction", "closest",
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||||
assert result.returncode == 0
|
||||
data = json.loads(result.stdout)
|
||||
assert data.get("target_key_int") == 0
|
||||
print(f" JSON transpose by-key: OK")
|
||||
finally:
|
||||
if os.path.exists(out):
|
||||
os.unlink(out)
|
||||
|
||||
@requires_mscore
|
||||
@requires_samples
|
||||
def test_full_workflow(self):
|
||||
"""Test a full workflow: info → transpose → export PDF → verify."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
transposed = os.path.join(tmpdir, "transposed.mscz")
|
||||
pdf_out = os.path.join(tmpdir, "output.pdf")
|
||||
|
||||
# 1. Transpose G → C
|
||||
cmd = _resolve_cli("cli-anything-musescore") + [
|
||||
"--json", "transpose", "by-key",
|
||||
"-i", str(_SAMPLE_MXL),
|
||||
"-o", transposed,
|
||||
"--target-key", "C major",
|
||||
]
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||||
assert r.returncode == 0, f"Transpose failed: {r.stderr}"
|
||||
|
||||
# 2. Export to PDF
|
||||
cmd = _resolve_cli("cli-anything-musescore") + [
|
||||
"--json", "export", "pdf",
|
||||
"-i", transposed,
|
||||
"-o", pdf_out,
|
||||
]
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||||
assert r.returncode == 0, f"Export failed: {r.stderr}"
|
||||
|
||||
# 3. Verify PDF
|
||||
with open(pdf_out, "rb") as f:
|
||||
assert f.read(5) == b"%PDF-"
|
||||
|
||||
print(f" Full workflow: transpose → export → verify: OK")
|
||||
@@ -0,0 +1,349 @@
|
||||
"""MSCX/MusicXML parsing utilities.
|
||||
|
||||
Handles reading and writing .mscz (ZIP containing .mscx XML) and
|
||||
.mxl (ZIP containing MusicXML) files, plus XML inspection helpers.
|
||||
"""
|
||||
|
||||
import os
|
||||
import xml.etree.ElementTree as ET
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ── Key Signature Mapping ─────────────────────────────────────────────
|
||||
|
||||
# Integer key signatures: -7 (Cb) to +7 (C#)
|
||||
# Negative = flats, positive = sharps, 0 = C major / A minor
|
||||
KEY_INT_TO_MAJOR = {
|
||||
-7: "Cb", -6: "Gb", -5: "Db", -4: "Ab", -3: "Eb", -2: "Bb", -1: "F",
|
||||
0: "C", 1: "G", 2: "D", 3: "A", 4: "E", 5: "B", 6: "F#", 7: "C#",
|
||||
}
|
||||
|
||||
KEY_INT_TO_MINOR = {
|
||||
-7: "Ab", -6: "Eb", -5: "Bb", -4: "F", -3: "C", -2: "G", -1: "D",
|
||||
0: "A", 1: "E", 2: "B", 3: "F#", 4: "C#", 5: "G#", 6: "D#", 7: "A#",
|
||||
}
|
||||
|
||||
# Reverse: name → integer (case-insensitive, supports "C major", "C", "Cm", "C minor")
|
||||
_KEY_NAME_TO_INT: dict[str, int] = {}
|
||||
for _i, _name in KEY_INT_TO_MAJOR.items():
|
||||
_KEY_NAME_TO_INT[_name.lower()] = _i
|
||||
_KEY_NAME_TO_INT[f"{_name.lower()} major"] = _i
|
||||
_KEY_NAME_TO_INT[f"{_name.lower()}maj"] = _i
|
||||
for _i, _name in KEY_INT_TO_MINOR.items():
|
||||
_KEY_NAME_TO_INT[f"{_name.lower()} minor"] = _i
|
||||
_KEY_NAME_TO_INT[f"{_name.lower()}m"] = _i
|
||||
_KEY_NAME_TO_INT[f"{_name.lower()}min"] = _i
|
||||
|
||||
|
||||
def key_name_to_int(name: str) -> int:
|
||||
"""Convert a key name to its integer representation.
|
||||
|
||||
Accepts: "C", "C major", "Db", "Db major", "A minor", "Am", etc.
|
||||
|
||||
Raises:
|
||||
ValueError: If the key name is not recognized.
|
||||
"""
|
||||
normalized = name.strip().lower()
|
||||
if normalized in _KEY_NAME_TO_INT:
|
||||
return _KEY_NAME_TO_INT[normalized]
|
||||
|
||||
raise ValueError(
|
||||
f"Unrecognized key name: '{name}'. "
|
||||
f"Examples: C, Db major, F# minor, Bb, Am"
|
||||
)
|
||||
|
||||
|
||||
def key_int_to_name(key_int: int, minor: bool = False) -> str:
|
||||
"""Convert a key integer to its name."""
|
||||
table = KEY_INT_TO_MINOR if minor else KEY_INT_TO_MAJOR
|
||||
if key_int not in table:
|
||||
raise ValueError(f"Invalid key integer: {key_int}. Must be -7 to 7.")
|
||||
suffix = " minor" if minor else " major"
|
||||
return table[key_int] + suffix
|
||||
|
||||
|
||||
# ── MSCZ (MuseScore ZIP) I/O ─────────────────────────────────────────
|
||||
|
||||
def read_mscz(path: str) -> dict:
|
||||
"""Read a .mscz file (ZIP archive).
|
||||
|
||||
Returns:
|
||||
Dict with keys:
|
||||
- "mscx": ElementTree of the .mscx XML
|
||||
- "mscx_filename": name of the .mscx file inside the ZIP
|
||||
- "style": content of score_style.mss (str or None)
|
||||
- "audio_settings": content of audiosettings.json (str or None)
|
||||
- "view_settings": content of viewsettings.json (str or None)
|
||||
- "other_files": dict of other filename → bytes
|
||||
"""
|
||||
result = {
|
||||
"mscx": None,
|
||||
"mscx_filename": None,
|
||||
"style": None,
|
||||
"audio_settings": None,
|
||||
"view_settings": None,
|
||||
"other_files": {},
|
||||
}
|
||||
|
||||
with zipfile.ZipFile(path, "r") as zf:
|
||||
for name in zf.namelist():
|
||||
if name.endswith(".mscx"):
|
||||
result["mscx_filename"] = name
|
||||
xml_bytes = zf.read(name)
|
||||
result["mscx"] = ET.ElementTree(ET.fromstring(xml_bytes))
|
||||
elif name == "score_style.mss" or name.endswith("/score_style.mss"):
|
||||
result["style"] = zf.read(name).decode("utf-8")
|
||||
elif name == "audiosettings.json" or name.endswith("/audiosettings.json"):
|
||||
result["audio_settings"] = zf.read(name).decode("utf-8")
|
||||
elif name == "viewsettings.json" or name.endswith("/viewsettings.json"):
|
||||
result["view_settings"] = zf.read(name).decode("utf-8")
|
||||
else:
|
||||
result["other_files"][name] = zf.read(name)
|
||||
|
||||
if result["mscx"] is None:
|
||||
raise ValueError(f"No .mscx file found inside {path}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def write_mscz(path: str, data: dict) -> Path:
|
||||
"""Write a .mscz file from component data.
|
||||
|
||||
Args:
|
||||
path: Output .mscz path.
|
||||
data: Dict as returned by read_mscz().
|
||||
|
||||
Returns:
|
||||
Path to the written file.
|
||||
"""
|
||||
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
# Write the .mscx XML
|
||||
mscx_filename = data.get("mscx_filename", "score.mscx")
|
||||
xml_str = ET.tostring(data["mscx"].getroot(), encoding="unicode",
|
||||
xml_declaration=True)
|
||||
zf.writestr(mscx_filename, xml_str)
|
||||
|
||||
# Write style
|
||||
if data.get("style"):
|
||||
zf.writestr("score_style.mss", data["style"])
|
||||
|
||||
# Write settings
|
||||
if data.get("audio_settings"):
|
||||
zf.writestr("audiosettings.json", data["audio_settings"])
|
||||
if data.get("view_settings"):
|
||||
zf.writestr("viewsettings.json", data["view_settings"])
|
||||
|
||||
# Write other files
|
||||
for name, content in data.get("other_files", {}).items():
|
||||
zf.writestr(name, content)
|
||||
|
||||
return Path(path)
|
||||
|
||||
|
||||
# ── MXL (MusicXML ZIP) I/O ───────────────────────────────────────────
|
||||
|
||||
def read_mxl(path: str) -> ET.ElementTree:
|
||||
"""Read a .mxl file (compressed MusicXML).
|
||||
|
||||
Returns:
|
||||
ElementTree of the MusicXML content.
|
||||
"""
|
||||
with zipfile.ZipFile(path, "r") as zf:
|
||||
# Look for the MusicXML file
|
||||
for name in zf.namelist():
|
||||
if name.endswith(".xml") and not name.startswith("META-INF"):
|
||||
xml_bytes = zf.read(name)
|
||||
return ET.ElementTree(ET.fromstring(xml_bytes))
|
||||
|
||||
raise ValueError(f"No MusicXML file found inside {path}")
|
||||
|
||||
|
||||
# ── XML Inspection Helpers ────────────────────────────────────────────
|
||||
|
||||
def get_key_signature(tree: ET.ElementTree) -> int | None:
|
||||
"""Extract the first key signature from a MusicXML or MSCX tree.
|
||||
|
||||
Returns:
|
||||
Integer key signature (-7 to 7), or None if not found.
|
||||
"""
|
||||
root = tree.getroot()
|
||||
|
||||
# MusicXML: <key><fifths>-5</fifths></key>
|
||||
fifths = root.find(".//{*}fifths")
|
||||
if fifths is not None and fifths.text:
|
||||
return int(fifths.text)
|
||||
|
||||
# MSCX: <KeySig><accidental>-5</accidental></KeySig>
|
||||
# or <KeySig><concertKey>-5</concertKey></KeySig>
|
||||
for tag in ["accidental", "concertKey"]:
|
||||
elem = root.find(f".//KeySig/{tag}")
|
||||
if elem is not None and elem.text:
|
||||
return int(elem.text)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_time_signature(tree: ET.ElementTree) -> str | None:
|
||||
"""Extract the first time signature.
|
||||
|
||||
Returns:
|
||||
String like "4/4", "3/4", etc., or None.
|
||||
"""
|
||||
root = tree.getroot()
|
||||
|
||||
# MusicXML: <time><beats>4</beats><beat-type>4</beat-type></time>
|
||||
beats = root.find(".//{*}beats")
|
||||
beat_type = root.find(".//{*}beat-type")
|
||||
if beats is not None and beat_type is not None:
|
||||
return f"{beats.text}/{beat_type.text}"
|
||||
|
||||
# MSCX: <TimeSig><sigN>4</sigN><sigD>4</sigD></TimeSig>
|
||||
sig_n = root.find(".//TimeSig/sigN")
|
||||
sig_d = root.find(".//TimeSig/sigD")
|
||||
if sig_n is not None and sig_d is not None:
|
||||
return f"{sig_n.text}/{sig_d.text}"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_instruments(tree: ET.ElementTree) -> list[dict]:
|
||||
"""Extract instrument info from a MusicXML or MSCX tree.
|
||||
|
||||
Returns:
|
||||
List of dicts with 'id', 'name', 'part_name' keys.
|
||||
"""
|
||||
root = tree.getroot()
|
||||
instruments = []
|
||||
|
||||
# MusicXML: <score-part id="P1"><part-name>Piano</part-name>
|
||||
# <score-instrument id="P1-I1"><instrument-name>Piano</instrument-name>
|
||||
for sp in root.findall(".//{*}score-part"):
|
||||
inst = {"id": sp.get("id", ""), "name": "", "part_name": ""}
|
||||
pn = sp.find("{*}part-name")
|
||||
if pn is None:
|
||||
pn = sp.find("part-name")
|
||||
if pn is not None:
|
||||
inst["part_name"] = pn.text or ""
|
||||
sin = sp.find(".//{*}instrument-name")
|
||||
if sin is not None:
|
||||
inst["name"] = sin.text or ""
|
||||
else:
|
||||
inst["name"] = inst["part_name"]
|
||||
instruments.append(inst)
|
||||
|
||||
if instruments:
|
||||
return instruments
|
||||
|
||||
# MSCX: <Part><Instrument id="keyboard.piano"><longName>Piano</longName>
|
||||
for part in root.findall(".//Part"):
|
||||
inst_elem = part.find("Instrument")
|
||||
if inst_elem is not None:
|
||||
inst = {
|
||||
"id": inst_elem.get("id", ""),
|
||||
"name": "",
|
||||
"part_name": "",
|
||||
}
|
||||
ln = inst_elem.find("longName")
|
||||
if ln is not None:
|
||||
inst["name"] = ln.text or ""
|
||||
sn = inst_elem.find("shortName")
|
||||
if sn is not None:
|
||||
inst["part_name"] = sn.text or inst["name"]
|
||||
else:
|
||||
inst["part_name"] = inst["name"]
|
||||
instruments.append(inst)
|
||||
|
||||
return instruments
|
||||
|
||||
|
||||
def get_score_title(tree: ET.ElementTree) -> str:
|
||||
"""Extract score title from XML."""
|
||||
root = tree.getroot()
|
||||
|
||||
# MusicXML: <work><work-title>...</work-title></work>
|
||||
# or <movement-title>...</movement-title>
|
||||
wt = root.find(".//{*}work-title")
|
||||
if wt is not None and wt.text:
|
||||
return wt.text
|
||||
mt = root.find(".//{*}movement-title")
|
||||
if mt is not None and mt.text:
|
||||
return mt.text
|
||||
|
||||
# MSCX: <metaTag name="workTitle">...</metaTag>
|
||||
for meta in root.findall(".//metaTag"):
|
||||
if meta.get("name") == "workTitle" and meta.text:
|
||||
return meta.text
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def count_measures(tree: ET.ElementTree) -> int:
|
||||
"""Count the number of measures in a score."""
|
||||
root = tree.getroot()
|
||||
|
||||
# MusicXML: count <measure> elements in the first part
|
||||
measures = root.findall(".//{*}measure")
|
||||
if measures:
|
||||
# Each part has its own measures; count the first part's
|
||||
first_part = root.find(".//{*}part")
|
||||
if first_part is not None:
|
||||
return len(first_part.findall("{*}measure"))
|
||||
return len(measures)
|
||||
|
||||
# MSCX: count <Measure> elements in the first staff
|
||||
mscx_measures = root.findall(".//Measure")
|
||||
if mscx_measures:
|
||||
first_staff = root.find(".//Staff")
|
||||
if first_staff is not None:
|
||||
return len(first_staff.findall("Measure"))
|
||||
return len(mscx_measures)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def count_notes(tree: ET.ElementTree) -> int:
|
||||
"""Count the number of notes in a score."""
|
||||
root = tree.getroot()
|
||||
|
||||
# MusicXML
|
||||
notes = root.findall(".//{*}note")
|
||||
if notes:
|
||||
return len(notes)
|
||||
|
||||
# MSCX
|
||||
return len(root.findall(".//Note"))
|
||||
|
||||
|
||||
def detect_format(path: str) -> str:
|
||||
"""Detect score file format from extension.
|
||||
|
||||
Returns:
|
||||
One of: "mscz", "mxl", "musicxml", "mid", "unknown"
|
||||
"""
|
||||
ext = Path(path).suffix.lower()
|
||||
return {
|
||||
".mscz": "mscz",
|
||||
".mxl": "mxl",
|
||||
".musicxml": "musicxml",
|
||||
".xml": "musicxml",
|
||||
".mid": "mid",
|
||||
".midi": "mid",
|
||||
}.get(ext, "unknown")
|
||||
|
||||
|
||||
def read_score_tree(path: str) -> ET.ElementTree:
|
||||
"""Read a score file and return its XML tree.
|
||||
|
||||
Supports .mscz, .mxl, .musicxml, .xml formats.
|
||||
"""
|
||||
fmt = detect_format(path)
|
||||
if fmt == "mscz":
|
||||
data = read_mscz(path)
|
||||
return data["mscx"]
|
||||
elif fmt == "mxl":
|
||||
return read_mxl(path)
|
||||
elif fmt == "musicxml":
|
||||
return ET.parse(path)
|
||||
else:
|
||||
raise ValueError(f"Cannot read XML tree from format: {fmt} ({path})")
|
||||
@@ -0,0 +1,277 @@
|
||||
"""Backend interface for MuseScore 4 CLI (mscore).
|
||||
|
||||
Finds the mscore binary and provides Python wrappers for all CLI operations:
|
||||
export, transpose, metadata, parts, media, diff, batch jobs.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def find_musescore() -> str:
|
||||
"""Locate the mscore executable.
|
||||
|
||||
Search order:
|
||||
1. MUSESCORE_PATH environment variable
|
||||
2. shutil.which("mscore")
|
||||
3. macOS app bundle: /Applications/MuseScore 4.app/Contents/MacOS/mscore
|
||||
4. Common Linux paths: /usr/bin/mscore4, /usr/local/bin/mscore4
|
||||
5. Windows: C:\\Program Files\\MuseScore 4\\bin\\MuseScore4.exe
|
||||
|
||||
Returns:
|
||||
Absolute path to the mscore binary.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If mscore cannot be found.
|
||||
"""
|
||||
# 1. Environment variable override
|
||||
env_path = os.environ.get("MUSESCORE_PATH")
|
||||
if env_path and os.path.isfile(env_path):
|
||||
return env_path
|
||||
|
||||
# 2. On PATH
|
||||
which = shutil.which("mscore")
|
||||
if which:
|
||||
return which
|
||||
|
||||
# 3. Platform-specific paths
|
||||
system = platform.system()
|
||||
candidates = []
|
||||
|
||||
if system == "Darwin":
|
||||
candidates = [
|
||||
"/Applications/MuseScore 4.app/Contents/MacOS/mscore",
|
||||
os.path.expanduser("~/Applications/MuseScore 4.app/Contents/MacOS/mscore"),
|
||||
]
|
||||
elif system == "Linux":
|
||||
candidates = [
|
||||
"/usr/bin/mscore4",
|
||||
"/usr/local/bin/mscore4",
|
||||
"/usr/bin/mscore",
|
||||
"/usr/local/bin/mscore",
|
||||
"/snap/musescore/current/bin/mscore4",
|
||||
]
|
||||
elif system == "Windows":
|
||||
candidates = [
|
||||
r"C:\Program Files\MuseScore 4\bin\MuseScore4.exe",
|
||||
r"C:\Program Files (x86)\MuseScore 4\bin\MuseScore4.exe",
|
||||
]
|
||||
|
||||
for path in candidates:
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
|
||||
raise RuntimeError(
|
||||
"MuseScore 4 (mscore) not found.\n\n"
|
||||
"Install MuseScore 4 from https://musescore.org/en/download\n\n"
|
||||
"Or set the MUSESCORE_PATH environment variable:\n"
|
||||
" export MUSESCORE_PATH=/path/to/mscore\n\n"
|
||||
"Expected locations:\n"
|
||||
" macOS: /Applications/MuseScore 4.app/Contents/MacOS/mscore\n"
|
||||
" Linux: /usr/bin/mscore4\n"
|
||||
" Windows: C:\\Program Files\\MuseScore 4\\bin\\MuseScore4.exe"
|
||||
)
|
||||
|
||||
|
||||
def _filter_qt_noise(stderr: str) -> str:
|
||||
"""Filter harmless Qt/QML warnings from mscore stderr."""
|
||||
if not stderr:
|
||||
return ""
|
||||
lines = []
|
||||
for line in stderr.splitlines():
|
||||
# Skip known Qt noise
|
||||
if any(pat in line for pat in [
|
||||
"qt.qml.typeregistration",
|
||||
"QML",
|
||||
"Qt WebEngine",
|
||||
"Fontconfig",
|
||||
"MESA-LOADER",
|
||||
"libpng warning",
|
||||
"IMKClient",
|
||||
"IMKInputSession",
|
||||
]):
|
||||
continue
|
||||
if line.strip():
|
||||
lines.append(line)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def run_mscore(args: list[str], capture_stdout: bool = True,
|
||||
timeout: int = 120) -> subprocess.CompletedProcess:
|
||||
"""Run mscore with the given arguments.
|
||||
|
||||
Args:
|
||||
args: Command-line arguments (not including the mscore binary itself).
|
||||
capture_stdout: Whether to capture stdout.
|
||||
timeout: Timeout in seconds.
|
||||
|
||||
Returns:
|
||||
CompletedProcess result.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If mscore exits with a non-zero code.
|
||||
"""
|
||||
mscore = find_musescore()
|
||||
cmd = [mscore] + args
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
filtered_stderr = _filter_qt_noise(result.stderr)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"mscore exited with code {result.returncode}\n"
|
||||
f"Command: {' '.join(cmd)}\n"
|
||||
f"stderr: {filtered_stderr or result.stderr}"
|
||||
)
|
||||
|
||||
result.stderr = filtered_stderr
|
||||
return result
|
||||
|
||||
|
||||
def export_score(input_path: str, output_path: str, *,
|
||||
dpi: int | None = None,
|
||||
bitrate: int | None = None,
|
||||
trim: int | None = None,
|
||||
style: str | None = None,
|
||||
sound_profile: str | None = None,
|
||||
export_parts: bool = False) -> Path:
|
||||
"""Export a score to the specified format via mscore -o.
|
||||
|
||||
Format is inferred from the output file extension.
|
||||
|
||||
Args:
|
||||
input_path: Path to input score (.mscz, .mxl, .mid).
|
||||
output_path: Path to output file (extension determines format).
|
||||
dpi: PNG resolution in DPI.
|
||||
bitrate: MP3 bitrate in kbps.
|
||||
trim: PNG/SVG whitespace trim margin.
|
||||
style: Path to .mss style file to apply.
|
||||
sound_profile: Audio profile ("MuseScore Basic" or "Muse Sounds").
|
||||
export_parts: Whether to append parts to PDF export.
|
||||
|
||||
Returns:
|
||||
Path to the output file.
|
||||
"""
|
||||
args = []
|
||||
|
||||
if style:
|
||||
args.extend(["-S", style])
|
||||
if dpi is not None:
|
||||
args.extend(["-r", str(dpi)])
|
||||
if bitrate is not None:
|
||||
args.extend(["-b", str(bitrate)])
|
||||
if trim is not None:
|
||||
args.extend(["-T", str(trim)])
|
||||
if sound_profile:
|
||||
args.extend(["--sound-profile", sound_profile])
|
||||
if export_parts:
|
||||
args.append("-P")
|
||||
|
||||
args.extend(["-o", str(output_path), str(input_path)])
|
||||
run_mscore(args)
|
||||
|
||||
return Path(output_path)
|
||||
|
||||
|
||||
def transpose_score(input_path: str, output_path: str,
|
||||
transpose_opts: dict) -> Path:
|
||||
"""Transpose a score and save the result.
|
||||
|
||||
Args:
|
||||
input_path: Path to input score.
|
||||
output_path: Path to output score.
|
||||
transpose_opts: Transpose options dict with keys:
|
||||
mode, direction, targetKey, transposeInterval,
|
||||
transposeKeySignatures, transposeChordNames,
|
||||
useDoubleSharpsFlats.
|
||||
|
||||
Returns:
|
||||
Path to the output file.
|
||||
"""
|
||||
opts_json = json.dumps(transpose_opts)
|
||||
args = ["--transpose", opts_json, "-o", str(output_path), str(input_path)]
|
||||
run_mscore(args)
|
||||
return Path(output_path)
|
||||
|
||||
|
||||
def get_score_meta(input_path: str) -> dict:
|
||||
"""Get score metadata via --score-meta.
|
||||
|
||||
Returns parsed JSON with title, composer, keysig, timesig,
|
||||
tempo, duration, measures, pages, parts, etc.
|
||||
The raw output wraps everything in a "metadata" key; we unwrap it.
|
||||
"""
|
||||
result = run_mscore(["--score-meta", str(input_path)])
|
||||
data = json.loads(result.stdout)
|
||||
# Unwrap the outer "metadata" envelope if present
|
||||
if "metadata" in data and isinstance(data["metadata"], dict):
|
||||
return data["metadata"]
|
||||
return data
|
||||
|
||||
|
||||
def get_score_parts(input_path: str) -> dict:
|
||||
"""Get score parts via --score-parts.
|
||||
|
||||
Returns parsed JSON with part names and base64-encoded .mscz data.
|
||||
"""
|
||||
result = run_mscore(["--score-parts", str(input_path)])
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def get_score_media(input_path: str) -> dict:
|
||||
"""Get all score media via --score-media.
|
||||
|
||||
Returns parsed JSON with pngs, svgs, pdf, midi, mxml, metadata, etc.
|
||||
"""
|
||||
result = run_mscore(["--score-media", str(input_path)])
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def diff_scores(file_a: str, file_b: str, raw: bool = False) -> dict:
|
||||
"""Diff two scores.
|
||||
|
||||
Args:
|
||||
file_a: Path to first score.
|
||||
file_b: Path to second score.
|
||||
raw: If True, use --raw-diff instead of --diff.
|
||||
|
||||
Returns:
|
||||
Parsed JSON diff result.
|
||||
"""
|
||||
flag = "--raw-diff" if raw else "--diff"
|
||||
result = run_mscore([flag, str(file_a), str(file_b)])
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def batch_convert(job_list: list[dict]) -> list[Path]:
|
||||
"""Run batch conversion via mscore -j.
|
||||
|
||||
Args:
|
||||
job_list: List of dicts with "in" and "out" keys.
|
||||
|
||||
Returns:
|
||||
List of output paths.
|
||||
"""
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".json", delete=False
|
||||
) as f:
|
||||
json.dump(job_list, f)
|
||||
job_file = f.name
|
||||
|
||||
try:
|
||||
run_mscore(["-j", job_file])
|
||||
finally:
|
||||
os.unlink(job_file)
|
||||
|
||||
return [Path(job["out"]) for job in job_list]
|
||||
@@ -0,0 +1,498 @@
|
||||
"""cli-anything REPL Skin — Unified terminal interface for all CLI harnesses.
|
||||
|
||||
Copy this file into your CLI package at:
|
||||
cli_anything/<software>/utils/repl_skin.py
|
||||
|
||||
Usage:
|
||||
from cli_anything.<software>.utils.repl_skin import ReplSkin
|
||||
|
||||
skin = ReplSkin("shotcut", version="1.0.0")
|
||||
skin.print_banner()
|
||||
prompt_text = skin.prompt(project_name="my_video.mlt", modified=True)
|
||||
skin.success("Project saved")
|
||||
skin.error("File not found")
|
||||
skin.warning("Unsaved changes")
|
||||
skin.info("Processing 24 clips...")
|
||||
skin.status("Track 1", "3 clips, 00:02:30")
|
||||
skin.table(headers, rows)
|
||||
skin.print_goodbye()
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# ── ANSI color codes (no external deps for core styling) ──────────────
|
||||
|
||||
_RESET = "\033[0m"
|
||||
_BOLD = "\033[1m"
|
||||
_DIM = "\033[2m"
|
||||
_ITALIC = "\033[3m"
|
||||
_UNDERLINE = "\033[4m"
|
||||
|
||||
# Brand colors
|
||||
_CYAN = "\033[38;5;80m" # cli-anything brand cyan
|
||||
_CYAN_BG = "\033[48;5;80m"
|
||||
_WHITE = "\033[97m"
|
||||
_GRAY = "\033[38;5;245m"
|
||||
_DARK_GRAY = "\033[38;5;240m"
|
||||
_LIGHT_GRAY = "\033[38;5;250m"
|
||||
|
||||
# Software accent colors — each software gets a unique accent
|
||||
_ACCENT_COLORS = {
|
||||
"gimp": "\033[38;5;214m", # warm orange
|
||||
"blender": "\033[38;5;208m", # deep orange
|
||||
"inkscape": "\033[38;5;39m", # bright blue
|
||||
"audacity": "\033[38;5;33m", # navy blue
|
||||
"libreoffice": "\033[38;5;40m", # green
|
||||
"obs_studio": "\033[38;5;55m", # purple
|
||||
"kdenlive": "\033[38;5;69m", # slate blue
|
||||
"shotcut": "\033[38;5;35m", # teal green
|
||||
}
|
||||
_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue
|
||||
|
||||
# Status colors
|
||||
_GREEN = "\033[38;5;78m"
|
||||
_YELLOW = "\033[38;5;220m"
|
||||
_RED = "\033[38;5;196m"
|
||||
_BLUE = "\033[38;5;75m"
|
||||
_MAGENTA = "\033[38;5;176m"
|
||||
|
||||
# ── Brand icon ────────────────────────────────────────────────────────
|
||||
|
||||
# The cli-anything icon: a small colored diamond/chevron mark
|
||||
_ICON = f"{_CYAN}{_BOLD}◆{_RESET}"
|
||||
_ICON_SMALL = f"{_CYAN}▸{_RESET}"
|
||||
|
||||
# ── Box drawing characters ────────────────────────────────────────────
|
||||
|
||||
_H_LINE = "─"
|
||||
_V_LINE = "│"
|
||||
_TL = "╭"
|
||||
_TR = "╮"
|
||||
_BL = "╰"
|
||||
_BR = "╯"
|
||||
_T_DOWN = "┬"
|
||||
_T_UP = "┴"
|
||||
_T_RIGHT = "├"
|
||||
_T_LEFT = "┤"
|
||||
_CROSS = "┼"
|
||||
|
||||
|
||||
def _strip_ansi(text: str) -> str:
|
||||
"""Remove ANSI escape codes for length calculation."""
|
||||
import re
|
||||
return re.sub(r"\033\[[^m]*m", "", text)
|
||||
|
||||
|
||||
def _visible_len(text: str) -> int:
|
||||
"""Get visible length of text (excluding ANSI codes)."""
|
||||
return len(_strip_ansi(text))
|
||||
|
||||
|
||||
class ReplSkin:
|
||||
"""Unified REPL skin for cli-anything CLIs.
|
||||
|
||||
Provides consistent branding, prompts, and message formatting
|
||||
across all CLI harnesses built with the cli-anything methodology.
|
||||
"""
|
||||
|
||||
def __init__(self, software: str, version: str = "1.0.0",
|
||||
history_file: str | None = None):
|
||||
"""Initialize the REPL skin.
|
||||
|
||||
Args:
|
||||
software: Software name (e.g., "gimp", "shotcut", "blender").
|
||||
version: CLI version string.
|
||||
history_file: Path for persistent command history.
|
||||
Defaults to ~/.cli-anything-<software>/history
|
||||
"""
|
||||
self.software = software.lower().replace("-", "_")
|
||||
self.display_name = software.replace("_", " ").title()
|
||||
self.version = version
|
||||
self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT)
|
||||
|
||||
# History file
|
||||
if history_file is None:
|
||||
from pathlib import Path
|
||||
hist_dir = Path.home() / f".cli-anything-{self.software}"
|
||||
hist_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.history_file = str(hist_dir / "history")
|
||||
else:
|
||||
self.history_file = history_file
|
||||
|
||||
# Detect terminal capabilities
|
||||
self._color = self._detect_color_support()
|
||||
|
||||
def _detect_color_support(self) -> bool:
|
||||
"""Check if terminal supports color."""
|
||||
if os.environ.get("NO_COLOR"):
|
||||
return False
|
||||
if os.environ.get("CLI_ANYTHING_NO_COLOR"):
|
||||
return False
|
||||
if not hasattr(sys.stdout, "isatty"):
|
||||
return False
|
||||
return sys.stdout.isatty()
|
||||
|
||||
def _c(self, code: str, text: str) -> str:
|
||||
"""Apply color code if colors are supported."""
|
||||
if not self._color:
|
||||
return text
|
||||
return f"{code}{text}{_RESET}"
|
||||
|
||||
# ── Banner ────────────────────────────────────────────────────────
|
||||
|
||||
def print_banner(self):
|
||||
"""Print the startup banner with branding."""
|
||||
inner = 54
|
||||
|
||||
def _box_line(content: str) -> str:
|
||||
"""Wrap content in box drawing, padding to inner width."""
|
||||
pad = inner - _visible_len(content)
|
||||
vl = self._c(_DARK_GRAY, _V_LINE)
|
||||
return f"{vl}{content}{' ' * max(0, pad)}{vl}"
|
||||
|
||||
top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}")
|
||||
bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}")
|
||||
|
||||
# Title: ◆ cli-anything · Shotcut
|
||||
icon = self._c(_CYAN + _BOLD, "◆")
|
||||
brand = self._c(_CYAN + _BOLD, "cli-anything")
|
||||
dot = self._c(_DARK_GRAY, "·")
|
||||
name = self._c(self.accent + _BOLD, self.display_name)
|
||||
title = f" {icon} {brand} {dot} {name}"
|
||||
|
||||
ver = f" {self._c(_DARK_GRAY, f' v{self.version}')}"
|
||||
tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}"
|
||||
empty = ""
|
||||
|
||||
print(top)
|
||||
print(_box_line(title))
|
||||
print(_box_line(ver))
|
||||
print(_box_line(empty))
|
||||
print(_box_line(tip))
|
||||
print(bot)
|
||||
print()
|
||||
|
||||
# ── Prompt ────────────────────────────────────────────────────────
|
||||
|
||||
def prompt(self, project_name: str = "", modified: bool = False,
|
||||
context: str = "") -> str:
|
||||
"""Build a styled prompt string for prompt_toolkit or input().
|
||||
|
||||
Args:
|
||||
project_name: Current project name (empty if none open).
|
||||
modified: Whether the project has unsaved changes.
|
||||
context: Optional extra context to show in prompt.
|
||||
|
||||
Returns:
|
||||
Formatted prompt string.
|
||||
"""
|
||||
parts = []
|
||||
|
||||
# Icon
|
||||
if self._color:
|
||||
parts.append(f"{_CYAN}◆{_RESET} ")
|
||||
else:
|
||||
parts.append("> ")
|
||||
|
||||
# Software name
|
||||
parts.append(self._c(self.accent + _BOLD, self.software))
|
||||
|
||||
# Project context
|
||||
if project_name or context:
|
||||
ctx = context or project_name
|
||||
mod = "*" if modified else ""
|
||||
parts.append(f" {self._c(_DARK_GRAY, '[')}")
|
||||
parts.append(self._c(_LIGHT_GRAY, f"{ctx}{mod}"))
|
||||
parts.append(self._c(_DARK_GRAY, ']'))
|
||||
|
||||
parts.append(self._c(_GRAY, " ❯ "))
|
||||
|
||||
return "".join(parts)
|
||||
|
||||
def prompt_tokens(self, project_name: str = "", modified: bool = False,
|
||||
context: str = ""):
|
||||
"""Build prompt_toolkit formatted text tokens for the prompt.
|
||||
|
||||
Use with prompt_toolkit's FormattedText for proper ANSI handling.
|
||||
|
||||
Returns:
|
||||
list of (style, text) tuples for prompt_toolkit.
|
||||
"""
|
||||
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
|
||||
tokens = []
|
||||
|
||||
tokens.append(("class:icon", "◆ "))
|
||||
tokens.append(("class:software", self.software))
|
||||
|
||||
if project_name or context:
|
||||
ctx = context or project_name
|
||||
mod = "*" if modified else ""
|
||||
tokens.append(("class:bracket", " ["))
|
||||
tokens.append(("class:context", f"{ctx}{mod}"))
|
||||
tokens.append(("class:bracket", "]"))
|
||||
|
||||
tokens.append(("class:arrow", " ❯ "))
|
||||
|
||||
return tokens
|
||||
|
||||
def get_prompt_style(self):
|
||||
"""Get a prompt_toolkit Style object matching the skin.
|
||||
|
||||
Returns:
|
||||
prompt_toolkit.styles.Style
|
||||
"""
|
||||
try:
|
||||
from prompt_toolkit.styles import Style
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
|
||||
|
||||
return Style.from_dict({
|
||||
"icon": "#5fdfdf bold", # cyan brand color
|
||||
"software": f"{accent_hex} bold",
|
||||
"bracket": "#585858",
|
||||
"context": "#bcbcbc",
|
||||
"arrow": "#808080",
|
||||
# Completion menu
|
||||
"completion-menu.completion": "bg:#303030 #bcbcbc",
|
||||
"completion-menu.completion.current": f"bg:{accent_hex} #000000",
|
||||
"completion-menu.meta.completion": "bg:#303030 #808080",
|
||||
"completion-menu.meta.completion.current": f"bg:{accent_hex} #000000",
|
||||
# Auto-suggest
|
||||
"auto-suggest": "#585858",
|
||||
# Bottom toolbar
|
||||
"bottom-toolbar": "bg:#1c1c1c #808080",
|
||||
"bottom-toolbar.text": "#808080",
|
||||
})
|
||||
|
||||
# ── Messages ──────────────────────────────────────────────────────
|
||||
|
||||
def success(self, message: str):
|
||||
"""Print a success message with green checkmark."""
|
||||
icon = self._c(_GREEN + _BOLD, "✓")
|
||||
print(f" {icon} {self._c(_GREEN, message)}")
|
||||
|
||||
def error(self, message: str):
|
||||
"""Print an error message with red cross."""
|
||||
icon = self._c(_RED + _BOLD, "✗")
|
||||
print(f" {icon} {self._c(_RED, message)}", file=sys.stderr)
|
||||
|
||||
def warning(self, message: str):
|
||||
"""Print a warning message with yellow triangle."""
|
||||
icon = self._c(_YELLOW + _BOLD, "⚠")
|
||||
print(f" {icon} {self._c(_YELLOW, message)}")
|
||||
|
||||
def info(self, message: str):
|
||||
"""Print an info message with blue dot."""
|
||||
icon = self._c(_BLUE, "●")
|
||||
print(f" {icon} {self._c(_LIGHT_GRAY, message)}")
|
||||
|
||||
def hint(self, message: str):
|
||||
"""Print a subtle hint message."""
|
||||
print(f" {self._c(_DARK_GRAY, message)}")
|
||||
|
||||
def section(self, title: str):
|
||||
"""Print a section header."""
|
||||
print()
|
||||
print(f" {self._c(self.accent + _BOLD, title)}")
|
||||
print(f" {self._c(_DARK_GRAY, _H_LINE * len(title))}")
|
||||
|
||||
# ── Status display ────────────────────────────────────────────────
|
||||
|
||||
def status(self, label: str, value: str):
|
||||
"""Print a key-value status line."""
|
||||
lbl = self._c(_GRAY, f" {label}:")
|
||||
val = self._c(_WHITE, f" {value}")
|
||||
print(f"{lbl}{val}")
|
||||
|
||||
def status_block(self, items: dict[str, str], title: str = ""):
|
||||
"""Print a block of status key-value pairs.
|
||||
|
||||
Args:
|
||||
items: Dict of label -> value pairs.
|
||||
title: Optional title for the block.
|
||||
"""
|
||||
if title:
|
||||
self.section(title)
|
||||
|
||||
max_key = max(len(k) for k in items) if items else 0
|
||||
for label, value in items.items():
|
||||
lbl = self._c(_GRAY, f" {label:<{max_key}}")
|
||||
val = self._c(_WHITE, f" {value}")
|
||||
print(f"{lbl}{val}")
|
||||
|
||||
def progress(self, current: int, total: int, label: str = ""):
|
||||
"""Print a simple progress indicator.
|
||||
|
||||
Args:
|
||||
current: Current step number.
|
||||
total: Total number of steps.
|
||||
label: Optional label for the progress.
|
||||
"""
|
||||
pct = int(current / total * 100) if total > 0 else 0
|
||||
bar_width = 20
|
||||
filled = int(bar_width * current / total) if total > 0 else 0
|
||||
bar = "█" * filled + "░" * (bar_width - filled)
|
||||
text = f" {self._c(_CYAN, bar)} {self._c(_GRAY, f'{pct:3d}%')}"
|
||||
if label:
|
||||
text += f" {self._c(_LIGHT_GRAY, label)}"
|
||||
print(text)
|
||||
|
||||
# ── Table display ─────────────────────────────────────────────────
|
||||
|
||||
def table(self, headers: list[str], rows: list[list[str]],
|
||||
max_col_width: int = 40):
|
||||
"""Print a formatted table with box-drawing characters.
|
||||
|
||||
Args:
|
||||
headers: Column header strings.
|
||||
rows: List of rows, each a list of cell strings.
|
||||
max_col_width: Maximum column width before truncation.
|
||||
"""
|
||||
if not headers:
|
||||
return
|
||||
|
||||
# Calculate column widths
|
||||
col_widths = [min(len(h), max_col_width) for h in headers]
|
||||
for row in rows:
|
||||
for i, cell in enumerate(row):
|
||||
if i < len(col_widths):
|
||||
col_widths[i] = min(
|
||||
max(col_widths[i], len(str(cell))), max_col_width
|
||||
)
|
||||
|
||||
def pad(text: str, width: int) -> str:
|
||||
t = str(text)[:width]
|
||||
return t + " " * (width - len(t))
|
||||
|
||||
# Header
|
||||
header_cells = [
|
||||
self._c(_CYAN + _BOLD, pad(h, col_widths[i]))
|
||||
for i, h in enumerate(headers)
|
||||
]
|
||||
sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
|
||||
header_line = f" {sep.join(header_cells)}"
|
||||
print(header_line)
|
||||
|
||||
# Separator
|
||||
sep_parts = [self._c(_DARK_GRAY, _H_LINE * w) for w in col_widths]
|
||||
sep_line = self._c(_DARK_GRAY, f" {'───'.join([_H_LINE * w for w in col_widths])}")
|
||||
print(sep_line)
|
||||
|
||||
# Rows
|
||||
for row in rows:
|
||||
cells = []
|
||||
for i, cell in enumerate(row):
|
||||
if i < len(col_widths):
|
||||
cells.append(self._c(_LIGHT_GRAY, pad(str(cell), col_widths[i])))
|
||||
row_sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
|
||||
print(f" {row_sep.join(cells)}")
|
||||
|
||||
# ── Help display ──────────────────────────────────────────────────
|
||||
|
||||
def help(self, commands: dict[str, str]):
|
||||
"""Print a formatted help listing.
|
||||
|
||||
Args:
|
||||
commands: Dict of command -> description pairs.
|
||||
"""
|
||||
self.section("Commands")
|
||||
max_cmd = max(len(c) for c in commands) if commands else 0
|
||||
for cmd, desc in commands.items():
|
||||
cmd_styled = self._c(self.accent, f" {cmd:<{max_cmd}}")
|
||||
desc_styled = self._c(_GRAY, f" {desc}")
|
||||
print(f"{cmd_styled}{desc_styled}")
|
||||
print()
|
||||
|
||||
# ── Goodbye ───────────────────────────────────────────────────────
|
||||
|
||||
def print_goodbye(self):
|
||||
"""Print a styled goodbye message."""
|
||||
print(f"\n {_ICON_SMALL} {self._c(_GRAY, 'Goodbye!')}\n")
|
||||
|
||||
# ── Prompt toolkit session factory ────────────────────────────────
|
||||
|
||||
def create_prompt_session(self):
|
||||
"""Create a prompt_toolkit PromptSession with skin styling.
|
||||
|
||||
Returns:
|
||||
A configured PromptSession, or None if prompt_toolkit unavailable.
|
||||
"""
|
||||
try:
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.history import FileHistory
|
||||
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
|
||||
style = self.get_prompt_style()
|
||||
|
||||
session = PromptSession(
|
||||
history=FileHistory(self.history_file),
|
||||
auto_suggest=AutoSuggestFromHistory(),
|
||||
style=style,
|
||||
enable_history_search=True,
|
||||
)
|
||||
return session
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
def get_input(self, pt_session, project_name: str = "",
|
||||
modified: bool = False, context: str = "") -> str:
|
||||
"""Get input from user using prompt_toolkit or fallback.
|
||||
|
||||
Args:
|
||||
pt_session: A prompt_toolkit PromptSession (or None).
|
||||
project_name: Current project name.
|
||||
modified: Whether project has unsaved changes.
|
||||
context: Optional context string.
|
||||
|
||||
Returns:
|
||||
User input string (stripped).
|
||||
"""
|
||||
if pt_session is not None:
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
tokens = self.prompt_tokens(project_name, modified, context)
|
||||
return pt_session.prompt(FormattedText(tokens)).strip()
|
||||
else:
|
||||
raw_prompt = self.prompt(project_name, modified, context)
|
||||
return input(raw_prompt).strip()
|
||||
|
||||
# ── Toolbar builder ───────────────────────────────────────────────
|
||||
|
||||
def bottom_toolbar(self, items: dict[str, str]):
|
||||
"""Create a bottom toolbar callback for prompt_toolkit.
|
||||
|
||||
Args:
|
||||
items: Dict of label -> value pairs to show in toolbar.
|
||||
|
||||
Returns:
|
||||
A callable that returns FormattedText for the toolbar.
|
||||
"""
|
||||
def toolbar():
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
parts = []
|
||||
for i, (k, v) in enumerate(items.items()):
|
||||
if i > 0:
|
||||
parts.append(("class:bottom-toolbar.text", " │ "))
|
||||
parts.append(("class:bottom-toolbar.text", f" {k}: "))
|
||||
parts.append(("class:bottom-toolbar", v))
|
||||
return FormattedText(parts)
|
||||
return toolbar
|
||||
|
||||
|
||||
# ── ANSI 256-color to hex mapping (for prompt_toolkit styles) ─────────
|
||||
|
||||
_ANSI_256_TO_HEX = {
|
||||
"\033[38;5;33m": "#0087ff", # audacity navy blue
|
||||
"\033[38;5;35m": "#00af5f", # shotcut teal
|
||||
"\033[38;5;39m": "#00afff", # inkscape bright blue
|
||||
"\033[38;5;40m": "#00d700", # libreoffice green
|
||||
"\033[38;5;55m": "#5f00af", # obs purple
|
||||
"\033[38;5;69m": "#5f87ff", # kdenlive slate blue
|
||||
"\033[38;5;75m": "#5fafff", # default sky blue
|
||||
"\033[38;5;80m": "#5fd7d7", # brand cyan
|
||||
"\033[38;5;208m": "#ff8700", # blender deep orange
|
||||
"\033[38;5;214m": "#ffaf00", # gimp warm orange
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
"""setup.py for cli-anything-musescore
|
||||
|
||||
Install with: pip install -e .
|
||||
"""
|
||||
|
||||
from setuptools import setup, find_namespace_packages
|
||||
|
||||
with open("cli_anything/musescore/README.md", "r", encoding="utf-8") as fh:
|
||||
long_description = fh.read()
|
||||
|
||||
setup(
|
||||
name="cli-anything-musescore",
|
||||
version="1.0.0",
|
||||
author="cli-anything contributors",
|
||||
author_email="",
|
||||
description="CLI harness for MuseScore 4 — transpose, export PDF/audio/MIDI, extract parts, manage instruments",
|
||||
long_description=long_description,
|
||||
long_description_content_type="text/markdown",
|
||||
url="https://github.com/HKUDS/CLI-Anything",
|
||||
packages=find_namespace_packages(include=["cli_anything.*"]),
|
||||
classifiers=[
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"Topic :: Multimedia :: Sound/Audio",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
],
|
||||
python_requires=">=3.10",
|
||||
install_requires=[
|
||||
"click>=8.0.0",
|
||||
"prompt-toolkit>=3.0.0",
|
||||
],
|
||||
extras_require={
|
||||
"dev": [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-cov>=4.0.0",
|
||||
],
|
||||
},
|
||||
entry_points={
|
||||
"console_scripts": [
|
||||
"cli-anything-musescore=cli_anything.musescore.musescore_cli:main",
|
||||
],
|
||||
},
|
||||
package_data={
|
||||
"cli_anything.musescore": ["skills/*.md"],
|
||||
},
|
||||
include_package_data=True,
|
||||
zip_safe=False,
|
||||
)
|
||||
@@ -17,6 +17,7 @@ Usage:
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import shlex
|
||||
import click
|
||||
from typing import Optional
|
||||
|
||||
@@ -800,7 +801,10 @@ def repl(project_path):
|
||||
_repl_help(skin)
|
||||
continue
|
||||
|
||||
args = line.split()
|
||||
try:
|
||||
args = shlex.split(line)
|
||||
except ValueError:
|
||||
args = line.split()
|
||||
try:
|
||||
cli.main(args, standalone_mode=False)
|
||||
except SystemExit:
|
||||
|
||||
@@ -17,6 +17,7 @@ Usage:
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import shlex
|
||||
import click
|
||||
from typing import Optional
|
||||
|
||||
@@ -467,8 +468,11 @@ def repl():
|
||||
skin.help(_repl_commands)
|
||||
continue
|
||||
|
||||
# Parse and execute command
|
||||
args = line.split()
|
||||
# Parse and execute command (shlex handles quoted strings with spaces)
|
||||
try:
|
||||
args = shlex.split(line)
|
||||
except ValueError:
|
||||
args = line.split()
|
||||
try:
|
||||
cli.main(args, standalone_mode=False)
|
||||
except SystemExit:
|
||||
|
||||
+31
-1
@@ -61,6 +61,20 @@
|
||||
"contributor": "CLI-Anything-Team",
|
||||
"contributor_url": "https://github.com/HKUDS/CLI-Anything"
|
||||
},
|
||||
{
|
||||
"name": "browser",
|
||||
"display_name": "Browser",
|
||||
"version": "1.0.0",
|
||||
"description": "Browser automation via DOMShell MCP server. Maps Chrome's Accessibility Tree to a virtual filesystem for agent-native navigation.",
|
||||
"requires": "Node.js, npx, Chrome + DOMShell extension",
|
||||
"homepage": "https://github.com/apireno/DOMShell",
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=browser/agent-harness",
|
||||
"entry_point": "cli-anything-browser",
|
||||
"skill_md": "browser/agent-harness/cli_anything/browser/skills/SKILL.md",
|
||||
"category": "web",
|
||||
"contributor": "furkankoykiran",
|
||||
"contributor_url": "https://github.com/furkankoykiran"
|
||||
},
|
||||
{
|
||||
"name": "comfyui",
|
||||
"display_name": "ComfyUI",
|
||||
@@ -197,7 +211,9 @@
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=ollama/agent-harness",
|
||||
"entry_point": "cli-anything-ollama",
|
||||
"skill_md": "ollama/agent-harness/cli_anything/ollama/skills/SKILL.md",
|
||||
"category": "ai"
|
||||
"category": "ai",
|
||||
"contributor": "CLI-Anything-Team",
|
||||
"contributor_url": "https://github.com/HKUDS/CLI-Anything"
|
||||
},
|
||||
{
|
||||
"name": "obs-studio",
|
||||
@@ -255,6 +271,20 @@
|
||||
"contributor": "Alex-wuhu",
|
||||
"contributor_url": "https://github.com/Alex-wuhu"
|
||||
},
|
||||
{
|
||||
"name": "musescore",
|
||||
"display_name": "MuseScore",
|
||||
"version": "1.0.0",
|
||||
"description": "CLI for music notation — transpose, export PDF/audio/MIDI, extract parts, manage instruments",
|
||||
"requires": "MuseScore 4 (musescore.org)",
|
||||
"homepage": "https://musescore.org",
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=musescore/agent-harness",
|
||||
"entry_point": "cli-anything-musescore",
|
||||
"skill_md": "musescore/agent-harness/cli_anything/musescore/skills/SKILL.md",
|
||||
"category": "music",
|
||||
"contributor": "tamvicky",
|
||||
"contributor_url": "https://github.com/tamvicky"
|
||||
},
|
||||
{
|
||||
"name": "sketch",
|
||||
"display_name": "Sketch",
|
||||
|
||||
@@ -143,17 +143,24 @@ class TestInstalledHarnesses:
|
||||
"""Verify each real harness has SKILL.md in the correct package location."""
|
||||
|
||||
HARNESSES = [
|
||||
("gimp", "gimp"),
|
||||
("blender", "blender"),
|
||||
("inkscape", "inkscape"),
|
||||
("adguardhome", "adguardhome"),
|
||||
("anygen", "anygen"),
|
||||
("audacity", "audacity"),
|
||||
("libreoffice", "libreoffice"),
|
||||
("obs-studio", "obs_studio"),
|
||||
("blender", "blender"),
|
||||
("comfyui", "comfyui"),
|
||||
("drawio", "drawio"),
|
||||
("gimp", "gimp"),
|
||||
("inkscape", "inkscape"),
|
||||
("kdenlive", "kdenlive"),
|
||||
("libreoffice", "libreoffice"),
|
||||
("mermaid", "mermaid"),
|
||||
("mubu", "mubu"),
|
||||
("notebooklm", "notebooklm"),
|
||||
("novita", "novita"),
|
||||
("obs-studio", "obs_studio"),
|
||||
("ollama", "ollama"),
|
||||
("shotcut", "shotcut"),
|
||||
("zoom", "zoom"),
|
||||
("drawio", "drawio"),
|
||||
("anygen", "anygen"),
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("dir_name,pkg_name", HARNESSES)
|
||||
@@ -186,4 +193,6 @@ class TestInstalledHarnesses:
|
||||
setup_path = repo_root / dir_name / "agent-harness" / "setup.py"
|
||||
content = setup_path.read_text()
|
||||
assert "package_data" in content, f"Missing package_data in {setup_path}"
|
||||
assert "skills/*.md" in content, f"Missing skills/*.md in package_data: {setup_path}"
|
||||
# Accept both glob style ("skills/*.md") and explicit style ("SKILL.md" in a .skills key)
|
||||
has_skill_ref = "skills/*.md" in content or ("skills" in content and "SKILL.md" in content)
|
||||
assert has_skill_ref, f"Missing skills/*.md or SKILL.md in package_data: {setup_path}"
|
||||
|
||||
@@ -25,6 +25,7 @@ Usage:
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import shlex
|
||||
import webbrowser
|
||||
import click
|
||||
from typing import Optional
|
||||
@@ -486,8 +487,11 @@ def repl():
|
||||
skin.help(_repl_commands)
|
||||
continue
|
||||
|
||||
# Parse and execute command
|
||||
args = line.split()
|
||||
# Parse and execute command (shlex handles quoted strings with spaces)
|
||||
try:
|
||||
args = shlex.split(line)
|
||||
except ValueError:
|
||||
args = line.split()
|
||||
try:
|
||||
cli.main(args, standalone_mode=False)
|
||||
except SystemExit:
|
||||
|
||||
Reference in New Issue
Block a user