Skill: mileszhang001-boom/agent-tool-scout (version 0.1.0)

--- name: clam-mac description: "Give AI hands to control any Mac app. Auto-discover installed apps, generate CLI wrappers, return structured JSON. Works with Music, Finder, Chrome, Word, Figma, and 20+ more." version: 0.1.0 metadata: openclaw: requires: bins: - osascript - pip os: - macos emoji: "🦀" homepage: https://github.com/mileszhang001-boom/cli-on-mac tags: - macos - automation - applescript - native-apps - cli-generator --- # CLAM — Give AI Hands to Control Mac Apps You can control any macOS application through CLAM. It auto-discovers scriptable apps on the user's Mac, generates CLI wrappers on the fly, and returns structured JSON — all without API keys or manual configuration. ## Setup If `clam` is not yet installed, run: ```bash pip install clam-mac ``` ## What You Can Do ### 1. Discover what apps are controllable ```bash clam --json scan ``` This returns a JSON array of all controllable apps with their command count, property count, and mode (full / ui / basic). ### 2. Install a CLI wrapper for any app ```bash clam install music # fuzzy match: "chrome" → "google-chrome" ``` This auto-generates a typed CLI with dozens to hundreds of commands. Takes ~10 seconds, zero configuration. ### 3. Execute commands and read structured data ```bash clam-music play clam-music set-sound-volume 50 clam-music --json get-current-track # → full JSON with 15+ fields clam-finder --json open ~/Documents clam-google-chrome --json get-url # → current tab URL ``` Every command returns JSON when called with `--json`. Use this for reasoning and decision-making. ### 4. Check command reliability before calling ```bash clam --json doctor music ``` This tells you which commands are fully supported vs. which have parameter type issues. Only call commands marked as `supported: true` for reliable results. ## Command Pattern ``` clam --json scan # discover apps clam --json info <app> # list all commands for an app clam install <app> # install wrapper (if not already) clam-<app> --json <command> [args] # execute clam --json doctor <app> # reliability check ``` ## Three Modes - **Full mode**: Apps with .sdef scripting definitions (Music, Finder, Chrome, Word) → dozens to hundreds of commands - **UI Scripting mode**: Apps without .sdef but with accessible menus (Figma, Slack, VS Code, Spotify) → menu-click automation - **Basic mode**: Fallback for all .app bundles (WeChat, DingTalk, WPS) → activate, quit, open file, get version The install command auto-selects the best mode. You don't need to specify it. ## Important Notes - macOS only — requires `osascript` (built-in on all Macs) - Some commands require Automation permission: System Settings > Privacy & Security > Automation - UI Scripting commands require Accessibility permission: System Settings > Privacy & Security > Accessibility - Commands execute via AppleScript with a 30-second timeout - Always use `--json` flag for structured output when processing results programmatically ## Lobster Pipeline Example CLAM-generated CLIs are standard shell commands, usable as Lobster pipeline steps: ```yaml steps: - run: clam-music set-sound-volume 20 - run: clam-music play - run: clam-finder open ~/Projects/current ```

path: README.md
content: <h1 align="center"><img src="assets/logo.png" alt="" width="64" style="vertical-align: middle;">&nbsp; CLAM: 给 AI 装上控制 Mac 的手</h1>

<p align="center">
  <a href="https://pypi.org/project/clam-mac/"><img src="https://img.shields.io/pypi/v/clam-mac?color=blue" alt="PyPI"></a>
  <a href="https://pypi.org/project/clam-mac/"><img src="https://img.shields.io/pypi/pyversions/clam-mac" alt="Python"></a>
  <a href="https://github.com/mileszhang001-boom/cli-on-mac/blob/main/LICENSE"><img src="https://img.shields.io/pypi/l/clam-mac" alt="License"></a>
</p>

<p align="center">
  <strong>你的 Mac 上有几十个应用，但 AI 一个都碰不到。<br>
  CLAM 自动扫描、生成 CLI 接口，让任何 AI Agent 用原生方式操控一切。</strong>
</p>

<p align="center">
  <a href="#-openclaw--lobster">OpenClaw Skill</a> · <a href="#-claude-code--mcp">Claude Code MCP</a> · <a href="#-30-秒上手人类版">命令行直接用</a> · <a href="#-为什么需要-clam">对比其他方案</a>
</p>

安装后，AI Agent 能调用应用原生能力播放音乐、操作 Word、发邮件、绘制 3D 模型——无需依赖 API，无需手动写一行适配代码。

## 🆚 为什么需要 CLAM？

现有方案让 AI 控制 Mac 应用，要么**覆盖面窄**，要么**根本做不到**：

| 现有方案 | 问题 |
|---------|------|
| 手写专用接口 (MCP/Skill) | 每个应用都要有人单独开发，大多数应用根本没有 |
| 浏览器自动化 | 只能控制网页，碰不到 Music、Finder、Word 等原生应用 |
| 让 AI 直接写 AppleScript | 语法诡异，生成的脚本经常报错，输出非结构化 |

**CLAM 一次性解决了这些问题：**

| CLAM 能力 | 效果 |
|-----------|------|
| `clam scan` | AI 自动发现你 Mac 上所有可控应用 |
| `clam install <app>` | 10 秒生成完整 CLI，零配置，不需要有人提前开发 |
| `--json` 输出 | 所有命令返回结构化 JSON，AI 可直接用于推理决策 |
| `clam doctor` | 提前告诉 AI 哪些命令可靠，避免盲试出错 |

## 🦀 给你的 AI 装上虾钳

```bash
pip install clam-mac                    # 从 PyPI 安装
```

或者从源码安装：

```bash
git clone https://github.com/mileszhang001-boom/cli-on-mac.git
cd cli-on-mac
pip install -e .                        # 开发模式
```

然后根据你使用的 AI Agent 选择集成方式：

### 🦞 OpenClaw / Lobster

CLAM 生成的每个 `clam-<app>` 都是标准 shell 命令，可直接作为 Lobster pipeline step：

```yaml
# focus-mode.lobster — 一键进入专注模式
steps:
  - run: clam-music set-sound-volume 20
  - run: clam-music play
  - run: clam-finder open ~/Projects/current
```

也可以作为 OpenClaw Skill 使用——AI 自动调用 `clam scan` 发现能力、`clam install` 安装、`clam-<app>` 执行。

### 🤖 Claude Code / MCP

```bash
claude mcp add clam -- clam-mcp         # 注册 MCP Server
```

完成了。去 Claude Code 里说一句：

> "帮我把音量调到 50，然后告诉我现在在放什么歌"

AI 自动扫描应用、安装 wrapper、执行命令——你不需要输入任何命令。

### 🔧 其他 Agent 框架

CLAM 生成的是标准 CLI 工具，任何能调用 shell 命令的 Agent 都可以直接使用：

```python
# LangChain / AutoGPT / 任何框架
import subprocess, json
result = subprocess.run(["clam-music", "--json", "get-current-track"], capture_output=True, text=True)
track = json.loads(result.stdout)  # 结构化 JSON，直接用
```

## 🖐 30 秒上手（人类版）

```bash
clam scan                              # 看看有什么
clam install music                     # 装一个试试
clam-music play                        # 播放
clam-music next-track                  # 下一首
clam-music set-sound-volume 80         # 音量 80
clam-music get-current-track           # 当前曲目全部信息（JSON）
```

## 🔧 三种模式，覆盖几乎所有应用

| 模式 | 原理 | 典型应用 | 能力 |
|------|------|---------|------|
| **完整模式** | 解析 .sdef 脚本定义 | Music、Finder、Chrome、Word | 几十到几百个命令 |
| **UI Scripting** | 通过菜单点击控制 | Figma、Slack、VS Code、Spotify | 菜单里能点的都能控 |
| **基础模式** | macOS 标准套件 | 钉钉、WPS、微信 | 前置/退出/打开文件/查版本 |

安装时自动选择最佳模式，不需要手动指定。

## 📖 完整命令

```bash
clam scan              # 扫描可控应用
clam install <app>     # 安装 CLI（模糊匹配：chrome → google-chrome）
clam info <app>        # 查看能力详情
clam doctor <app>      # 检查命令可靠性
clam list              # 查看已安装
clam remove <app>      # 卸载

clam-<app>             # 能力概览
clam-<app> api         # 完整 API 列表
```

## ❓ 常见问题

**找不到 `clam-xxx`？** — `source .venv/bin/activate`

**"permission denied"？** — 系统设置 > 隐私与安全性 > 自动化，允许终端控制目标应用

**UI Scripting 报权限错误？** — 系统设置 > 隐私与安全性 > 辅助功能，允许终端

**只有基础命令？** — 取决于应用开发者，国产应用大多没有脚本化接口，只能基础模式


path: demo.py
content: """Interactive demo: control Music.app from your terminal."""

import json
import subprocess
import time


def music(cmd: str) -> str:
    result = subprocess.run(
        ["app-agent-music"] + cmd.split(),
        capture_output=True, text=True, timeout=15,
    )
    return result.stdout.strip()


def search_and_play(keyword: str) -> str:
    """Search library for a song and play the first match."""
    script = f'''
tell application "Music"
    set results to every track of library playlist 1 whose name contains "{keyword}"
    if results is not {{}} then
        play item 1 of results
        set t to item 1 of results
        return (name of t) & " - " & (artist of t)
    else
        return "未找到"
    end if
end tell
'''
    result = subprocess.run(
        ["osascript", "-e", script],
        capture_output=True, text=True, timeout=15,
    )
    return result.stdout.strip() or result.stderr.strip()


def main():
    print("\n🎵 Music.app 远程控制 Demo\n")

    # 1. 打开 Music 并放到前台
    print("▸ 打开 Music.app 并置于前台")
    music("set-frontmost true")
    time.sleep(1)
    print(f"  应用: {music('get-name')} v{music('get-version')}")
    print(f"  状态: {music('get-player-state')}")
    print(f"  音量: {music('get-sound-volume')}")

    # 2.
    time.sleep(2)

    # 3. 调节音量
    print("\n▸ 调节音量 → 50")
    music("set-sound-volume 50")
    print(f"  音量: {music('get-sound-volume')}")

    # 4. 搜索「大城小爱」并播放
    print("\n▸ 搜索「大城小爱」并播放")
    match = search_and_play("大城小爱")
    print(f"  匹配: {match}")
    time.sleep(1)
    print(f"  状态: {music('get-player-state')}")
    print("  ♪ 播放中 ...")
    time.sleep(8)

    # 5. 下一首
    print("\n▸ 下一首")
    music("next-track")
    time.sleep(2)
    print(f"  切换到: {music('get-current-track-name')} - {music('get-current-track-artist')}")
    print("  ♪ 播放中 ...")
    time.sleep(8)

    # 6. 获取当前曲目信息（复合 JSON 命令）
    print("\n▸ 当前曲目详情")
    raw = music("--json get-current-track")
    try:
        for k, v in json.loads(raw).items():
            print(f"  {k}: {v}")
    except Exception:
        print(f"  {raw}")

    # 7.
    time.sleep(2)

    # 8. 停止播放，恢复音量
    print("\n▸ 停止播放，恢复音量")
    music("stop")
    music("set-sound-volume 73")
    print(f"  状态: {music('get-player-state')}")
    print(f"  音量: {music('get-sound-volume')}")

    print("\n✅ Demo 结束\n")


if __name__ == "__main__":
    main()


path: demo_recording.py
content: #!/usr/bin/env python3
"""
CLAM Demo Recording Script
===========================
用于录制 README 演示 GIF/视频的脚本。

录制方式:
    # 推荐: asciinema (终端录制 → 转 GIF)
    brew install asciinema agg
    asciinema rec demo.cast -c "python demo_recording.py"
    agg demo.cast demo.gif --cols 90 --rows 32 --speed 1.5

    # 或者: 直接运行观看效果
    python demo_recording.py

前置要求:
    pip install clam-mac      # 安装 CLAM (或 pip install -e . 用开发模式)
    clam remove music 2>/dev/null   # 清除旧的 wrapper (确保演示 install 流程)

场景设计原则:
    每个场景都瞄准一个「现有工具链做不到 / 不好做」的真实问题。
    不是在展示功能，而是在展示「为什么你需要 CLAM」。
"""

from __future__ import annotations

import json
import subprocess
import sys
import time
from typing import Any

# ── Rich 输出 ────────────────────────────────────────────────────────────────

from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
from rich import box

console = Console(width=88)

# ── 配置 ──────────────────────────────────────────────────────────────────────

# 每行输出后暂停，方便录屏观看。设为 0 可快速跑完。
PACE = float(sys.argv[1]) if len(sys.argv) > 1 else 1.0


def pause(seconds: float = 1.0):
    time.sleep(seconds * PACE)


def run(cmd: str, timeout: int = 30) -> str:
    """执行命令并返回 stdout。"""
    result = subprocess.run(
        cmd.split(), capture_output=True, text=True, timeout=timeout,
    )
    if result.returncode != 0 and result.stderr.strip():
        return f"[ERROR] {result.stderr.strip()}"
    return result.stdout.strip()


def run_json(cmd: str) -> Any:
    """执行命令并解析 JSON 输出。"""
    raw = run(cmd)
    try:
        return json.loads(raw)
    except json.JSONDecodeError:
        return raw


def title_card(title: str, subtitle: str, problem: str):
    """场景标题卡，带问题陈述。"""
    console.print()
    console.rule(style="cyan")
    header = Text()
    header.append(f"  {title}\n", style="bold cyan")
    header.append(f"  {subtitle}\n\n", style="dim")
    header.append(f"  ❌ 痛点: ", style="bold red")
    header.append(problem, style="red")
    console.print(Panel(header, box=box.HEAVY, border_style="cyan", padding=(1, 2)))
    pause(3)


def step(icon: str, text: str):
    """步骤提示。"""
    console.print(f"\n  {icon}  [bold]{text}[/bold]")
    pause(0.5)


def contrast(clam_way: str, old_way: str):
    """对比展示 CLAM vs 传统方式。"""
    t = Table(show_header=True, box=box.SIMPLE_HEAVY, padding=(0, 2))
    t.add_column("CLAM", style="green", width=38)
    t.add_column("传统方式", style="red dim", width=38)
    t.add_row(clam_way, old_way)
    console.print(t)
    pause(2)


def show_cmd(cmd: str):
    """展示即将执行的命令。"""
    console.print(f"    [dim]$[/dim] [bold green]{cmd}[/bold green]")
    pause(0.8)


# ═══════════════════════════════════════════════════════════════════════════════
#  场景 1: 自主发现 → 即装即用
# ═══════════════════════════════════════════════════════════════════════════════
#
#  这个场景命中的核心问题:
#
#  现有工具链中，AI Agent 只能使用「预先配置好」的工具。如果你想让 AI
#  控制一个 Mac 应用，典型流程是:
#    1. 去 GitHub / ClawHub 搜索该应用的 Skill 或 MCP     (~5 分钟)
#    2. 发现根本没有人写过 → 放弃                         (大概率)
#    3. 即使找到了: 安装 → 编辑配置 → 重启 Agent           (~10 分钟)
#    4. 发现版本不兼容 / 接口不全 → 再找替代方案            (~∞)
#
#  CLAM 的解法:
#    clam scan     → AI 自己发现你的 Mac 上有什么可以控制
#    clam install  → 10 秒自动生成完整 CLI, 无需任何配置
#    直接使用      → 立刻可用, 不需要重启任何东西
#
#  这是 CLAM 最独特的价值: 整条链路全自动, 从未知到可控只需 10 秒。
#  没有任何现有工具提供这种 discovery → generation → ready 的一体化能力。

def scene_1():
    title_card(
        "场景 1: 自主发现 → 即装即用",
        "你的 Mac 上有几十个应用, 但 AI 一个都碰不到",
        "让 AI 控制一个新应用, 传统方式需要找 Skill/MCP → 安装 → 配置 → 测试 ≈ 30+ 分钟\n"
        "           而且大多数应用根本没有现成的 Skill/MCP 可用",
    )

    # ── Step 1: Scan ──
    step("🔍", "扫描: 你的 Mac 上有什么可以控制?")
    show_cmd("clam scan --json")

    raw = run("clam scan --json")
    try:
        apps = json.loads(raw)
    except json.JSONDecodeError:
        console.print(f"    [red]scan 失败: {raw[:200]}[/red]")
        return

    # 展示发现的应用表格
    t = Table(
        title=f"  发现 {len(apps)} 个可控应用",
        box=box.ROUNDED,
        title_style="bold green",
        padding=(0, 1),
        show_lines=False,
    )
    t.add_column("应用", style="bold", min_width=18)
    t.add_column("命令数", justify="right", style="cyan")
    t.add_column("属性数", justify="right", style="cyan")
    t.add_column("模式", justify="center")

    mode_style = {"full": "[green]完整[/green]", "ui": "[yellow]UI[/yellow]", "basic": "[dim]基础[/dim]"}
    for app in apps[:15]:  # 最多展示 15 个，避免屏幕太满
        t.add_row(
            app["name"],
            str(app.get("commands", 0)),
            str(app.get("properties", 0)),
            mode_style.get(app.get("mode", ""), app.get("mode", "")),
        )
    if len(apps) > 15:
        t.add_row(f"[dim]... 还有 {len(apps) - 15} 个应用[/dim]", "", "", "")

    console.print(t)
    pause(3)
    console.print(f"    [green]✓[/green] [bold]{len(apps)}[/bold] 个应用的 CLI 接口都可以自动生成, 无需手动编写")
    pause(2)

    # ── Step 2: Install ──
    step("📦", "安装: 给 Music 生成 CLI (10 秒, 零配置)")
    show_cmd("clam install music --json")

    result = run_json("clam install music --json")
    if isinstance(result, dict) and "error" not in result:
        console.print(f"    [green]✓[/green] 已生成 [bold cyan]clam-music[/bold cyan] — "
                       f"{result.get('commands', '?')} 个命令, {result.get('properties', '?')} 个属性")
    else:
        console.print(f"    [yellow]⚠[/yellow] {result}")
    pause(2)

    # ── Step 3: Immediate Use ──
    step("▶️ ", "立刻使用: 无需重启, 无需配置")
    show_cmd("clam-music play")
    run("clam-music play")
    console.print("    [green]✓[/green] 音乐已开始播放")
    pause(2)

    show_cmd("clam-music set-sound-volume 35")
    run("clam-music set-sound-volume 35")
    console.print("    [green]✓[/green] 音量 → 35")
    pause(1)

    # ── Contrast ──
    contrast(
        "clam scan → install → 立刻可用\n⏱ 10 秒, 零配置",
        "搜索 Skill → 安装 → 编辑配置 → 重启 Agent\n⏱ 30+ 分钟, 如果能找到的话",
    )


# ═══════════════════════════════════════════════════════════════════════════════
#  场景 2: 深度数据感知 — 结构化数据 vs 表面操作
# ═══════════════════════════════════════════════════════════════════════════════
#
#  这个场景命中的核心问题:
#
#  现有工具让 AI「操作」应用的方式是表面级的:
#    - 浏览器自动化: 截图 → OCR → 识别文字 → 猜测状态 (慢、不准)
#    - Siri / Shortcuts: 只能执行预设动作, 无法把数据返回给 AI
#    - 原生 AppleScript: AI 生成的脚本经常语法错误, 输出是非结构化文本
#
#  但 AI 做决策需要的是「数据」, 不是「像素」。
#
#  CLAM 的解法:
#    每个命令都返回 JSON 格式的结构化数据。
#    AI 可以直接读取 15+ 个字段 (歌名、艺术家、专辑、时长、评分、播放次数...)
#    然后基于数据做推理和决策 — 这是其他工具做不到的。
#
#  关键差异: 其他工具让 AI「点按钮」, CLAM 让 AI「读数据后做判断」。

def scene_2():
    title_card(
        "场景 2: 深度数据感知",
        "AI 需要的是数据, 不是像素",
        "浏览器自动化只能截图识别; Siri/Shortcuts 无法返回结构化数据给 AI\n"
        "           AI 无法基于应用状态做智能决策",
    )

    # ── Step 1: 获取完整曲目数据 ──
    step("📊", "读取当前曲目: 获取结构化数据")
    show_cmd("clam-music --json get-current-track")
    raw = run("clam-music --json get-current-track")
    try:
        track = json.loads(raw)
    except json.JSONDecodeError:
        console.print(f"    [yellow]⚠ 无法解析 (Music 可能未在播放): {raw[:100]}[/yellow]")
        track = {}

    if track:
        t = Table(
            title="  当前曲目 — 结构化 JSON 数据",
            box=box.ROUNDED,
            title_style="bold green",
            show_lines=False,
            padding=(0, 1),
        )
        t.add_column("字段", style="cyan", min_width=16)
        t.add_column("值", style="bold", min_width=30)

        # 选择最有代表性的字段展示
        display_keys = [
            "name", "artist", "album", "genre", "year",
            "duration", "rating", "played-count", "loved",
            "bit-rate", "sample-rate",
        ]
        shown = 0
        for key in display_keys:
            if key in track:
                t.add_row(key, str(track[key]))
                shown += 1
        # 补充其他字段
        for key, val in track.items():
            if key not in display_keys and shown < 12:
                t.add_row(key, str(val))
                shown += 1

        console.print(t)
        pause(3)

        total_fields = len(track)
        console.print(f"    [green]✓[/green] 共 [bold]{total_fields}[/bold] 个字段, "
                       "AI 可直接用于推理和决策")
        pause(2)

        # ── Step 2: 基于数据做决策 ──
        step("🧠", "AI 智能决策: 基于数据自动执行")

        # 示例: 根据评分决定是否跳过
        rating = track.get("rating", 0)
        name = track.get("name", "未知")
        artist = track.get("artist", "未知")

        console.print(f'    [dim]当前播放:[/dim] {name} — {artist}')

        if isinstance(rating, (int, float)) and rating > 0:
            if rating >= 60:
                console.print(f'    [dim]评分: {rating}/100[/dim] → [green]评分不错, 继续播放[/green]')
            else:
                console.print(f'    [dim]评分: {rating}/100[/dim] → [yellow]评分较低, 自动跳到下一首[/yellow]')
                run("clam-music next-track")
                pause(1)
                new_raw = run("clam-music --json get-current-track")
                try:
                    new_track = json.loads(new_raw)
                    console.print(f'    [green]✓[/green] 已切换到: '
                                   f'{new_track.get("name", "?")} — {new_track.get("artist", "?")}')
                except Exception:
                    pass
        else:
            # 无评分数据时, 演示播放次数决策
            played = track.get("played-count", 0)
            console.print(f'    [dim]播放次数: {played}[/dim] → ', end="")
            if isinstance(played, int) and played > 10:
                console.print("[green]常听曲目, 继续播放[/green]")
            else:
                console.print("[yellow]新发现的曲目, 标记关注[/yellow]")

        pause(2)
    else:
        console.print("    [dim]（请先在 Music.app 中播放一首歌再运行此场景）[/dim]")

    # ── Contrast ──
    contrast(
        "一条命令 → 15+ 字段的 JSON\nAI 可基于数据做推理和决策",
        "浏览器截图 → OCR → 只拿到歌名文字\n无法获取评分/时长/播放次数等元数据",
    )


# ═══════════════════════════════════════════════════════════════════════════════
#  场景 3: 可靠性保障 — doctor 诊断 + 参数安全
# ═══════════════════════════════════════════════════════════════════════════════
#
#  这个场景命中的核心问题:
#
#  让 AI 直接写 AppleScript 是目前最常见的「快捷方案」, 但:
#    - AppleScript 语法诡异, AI 经常生成无法运行的脚本
#    - 参数类型错误 (把字符串传给整数参数) 导致无声失败
#    - AI 不知道哪些命令是安全可用的, 哪些会报错
#    - 没有任何反馈机制 — 失败了 AI 也不知道为什么
#
#  CLAM 的解法:
#    - 代码生成阶段: py_compile 编译验证, 类型安全的参数处理
#    - 使用前: `clam doctor` 分析每个命令的可靠性
#    - 运行时: 枚举参数用 choices 校验, 类型自动转换
#
#  关键差异: CLAM 在 AI 使用之前就告诉它「什么能用, 什么不能用」,
#  避免了 trial-and-error 的黑盒调试过程。

def scene_3():
    title_card(
        "场景 3: 可靠性保障",
        "AI 写 AppleScript 常出错, CLAM 提前帮你排雷",
        "AI 直接生成的 AppleScript 经常因为类型错误、语法问题静默失败\n"
        "           AI 不知道哪些命令可靠, 哪些会出错 — 只能盲试",
    )

    # ── Step 1: Doctor 诊断 ──
    step("🩺", "诊断: 哪些命令可靠可用?")
    show_cmd("clam doctor music --json")

    raw = run("clam doctor music --json")
    try:
        report = json.loads(raw)
    except json.JSONDecodeError:
        console.print(f"    [yellow]⚠ 解析失败[/yellow]")
        return

    total = report.get("total", 0)
    supported = report.get("supported", 0)
    unsupported = report.get("unsupported", 0)

    # 概览
    pct = (supported / total * 100) if total > 0 else 0
    console.print(f"    [bold]{total}[/bold] 个命令中, "
                   f"[green]{supported} 个完全可靠[/green], "
                   f"[yellow]{unsupported} 个有兼容性问题[/yellow]"
                   f"  ([bold]{pct:.0f}%[/bold] 可用率)")
    pause(2)

    # 展示几个可靠 + 几个有问题的
    commands = report.get("commands", [])
    ok_cmds = [c for c in commands if c.get("supported")]
    bad_cmds = [c for c in commands if not c.get("supported")]

    t = Table(box=box.ROUNDED, padding=(0, 1), show_lines=False)
    t.add_column("命令", style="bold", min_width=24)
    t.add_column("状态", justify="center", min_width=8)
    t.add_column("说明", min_width=30)

    for cmd in ok_cmds[:5]:
        t.add_row(cmd["name"], "[green]✓ 可靠[/green]", "[dim]所有参数类型已支持[/dim]")
    for cmd in bad_cmds[:3]:
        issues = ", ".join(cmd.get("issues", [])[:2])
        t.add_row(cmd["name"], "[yellow]⚠ 风险[/yellow]", f"[dim]{issues}[/dim]")

    console.print(t)
    pause(3)

    # ── Step 2: 带参数执行 — 类型安全 ──
    step("🔒", "类型安全: 参数自动校验")

    console.print("    [dim]设置音量 (整数参数, 自动类型转换):[/dim]")
    show_cmd("clam-music set-sound-volume 45")
    run("clam-music set-sound-volume 45")
    vol = run("clam-music --json get-sound-volume")
    console.print(f"    [green]✓[/green] 音量已设为: {vol}")
    pause(1)

    # ── Contrast ──
    contrast(
        "clam doctor 预先诊断 → AI 只调用可靠命令\n参数类型自动校验, 枚举值自动约束",
        "AI 写 AppleScript → 运行 → 报错 → 重试\n盲试循环, 浪费 3-5 轮对话才能成功",
    )


# ═══════════════════════════════════════════════════════════════════════════════
#  Main
# ═══════════════════════════════════════════════════════════════════════════════

def main():
    console.print()
    # 开场
    banner = Text()
    banner.append("🦀 CLAM", style="bold cyan")
    banner.append(" — 给你的 AI 装上控制 Mac 的手\n\n", style="bold")
    banner.append("你的 Mac 上有几十个应用, 但 AI 一个都碰不到。\n", style="dim")
    banner.append("CLAM 一键生成 CLI 接口, 让 AI Agent 用原生方式操控一切。", style="dim")
    console.print(Panel(banner, box=box.DOUBLE, border_style="cyan", padding=(1, 3)))
    pause(3)

    scene_1()
    scene_2()
    scene_3()

    # 收尾
    console.print()

    # 恢复状态
    run("clam-music set-sound-volume 73")
    run("clam-music stop")

    # 结语
    ending = Text()
    ending.append("pip install clam-mac\n\n", style="bold green")
    ending.append("一行命令, 让你的 AI 控制一切。\n", style="dim")
    ending.append("OpenClaw Skill · Claude Code MCP · 或任何 Agent 框架", style="dim")
    console.print(Panel(ending, title="[bold cyan]开始使用[/bold cyan]",
                         box=box.DOUBLE, border_style="cyan", padding=(1, 3)))
    console.print()


if __name__ == "__main__":
    main()


path: docs/README-AI.md
content: # CLAM — CLI / MCP / Lobster Reference

CLAM 把 macOS 应用变成 CLI 命令，你可以直接在终端调用 `clam-<app>` 来控制应用。

> **Agent 集成**：可作为 OpenClaw Skill / Lobster pipeline step / Claude Code MCP Server / 或任何能调用 shell 的 Agent 框架使用。

## CLI 命令

```bash
clam --json scan                      # 发现可控应用
clam --json info music                # 查看 Music 的全部能力
clam install music                    # 安装（如果还没装的话）
clam-music --json get-current-track   # 调用
```

## 完整示例：控制 Music

```bash
# 播放控制
clam-music play
clam-music pause
clam-music next-track

# 读取属性
clam-music get-sound-volume             # → 73
clam-music get-player-state             # → playing

# 写入属性
clam-music set-sound-volume 50

# 嵌套对象（当前曲目的详细信息）
clam-music get-current-track-name       # → Slow Devotion
clam-music get-current-track-artist     # → Leon Vynehall
clam-music --json get-current-track     # → 全部属性 JSON
```

## JSON API

所有 `clam` 和 `clam-<app>` 命令都支持 `--json` 输出

### `clam --json scan`

```json
[
  {
    "name": "Music",
    "app_id": "music",
    "commands": 19,
    "properties": 19,
    "nested_groups": 6,
    "installed": true,
    "mode": "full"
  },
  {
    "name": "Figma",
    "app_id": "figma",
    "commands": 0,
    "properties": 0,
    "mode": "ui"
  }
]
```

`mode`: `"full"` = 完整 AppleScript 支持, `"ui"` = UI Scripting (菜单点击)

### `clam --json info <app_id>`

返回完整能力清单，**调用前先读这个，了解有哪些命令可用**

App ID 支持模糊匹配：`chrome` → `google-chrome`，`word` → `microsoft-word`

```json
{
  "app_id": "music",
  "app_name": "Music",
  "entry_point": "clam-music",
  "commands": [
    {
      "name": "play",
      "description": "play the current track or the specified track or file.",
      "has_direct_param": true,
      "params": [{"name": "--once", "description": "If true, play this track once and then stop."}]
    }
  ],
  "properties": [
    {
      "name": "sound-volume",
      "access": "rw",
      "type": "int",
      "get_command": "get-sound-volume",
      "set_command": "set-sound-volume"
    }
  ],
  "nested_groups": [
    {
      "name": "current-track",
      "compound_command": "get-current-track",
      "properties": [
        {"name": "name", "access": "rw", "get_command": "get-current-track-name"}
      ]
    }
  ]
}
```

### `clam --json install <app_id>`

```json
{"app_id": "music", "entry_point": "clam-music", "commands": 19, "properties": 19, "mode": "full"}
```

## 命令模式

```bash
clam-music play                         # 动作命令
clam-music play "some file"             # 带位置参数
clam-music export "playlist" --as m3u   # 带命名参数

clam-music get-sound-volume             # 读属性 → 73
clam-music set-sound-volume 50          # 写属性

clam-music get-current-track-name       # 读嵌套对象属性
clam-music --json get-current-track     # 读嵌套对象全部属性
```

## 错误处理

非零退出码 + stderr 输出，常见错误：

- `permission denied` → 需要在系统设置中授权自动化权限
- `accessibility access denied` → 需要辅助功能权限（UI Scripting 模式）
- `object not available` → 对象不存在（如没有正在播放的曲目）

## 注意事项

- 命令通过 `osascript` 执行，超时 30 秒
- 属性类型：`str`/`int`/`float`/`bool`，布尔值接受 `true`/`false`
- UI Scripting 模式需要目标应用正在运行
- 用 `clam-<app> api` 可以快速查看某个应用的全部可用命令


path: docs/README.md
content: <h1 align="center"><img src="assets/logo.png" alt="" width="64" style="vertical-align: middle;">&nbsp; CLAM: 给 AI 装上控制 Mac 的手</h1>

<p align="center">
  <a href="https://pypi.org/project/clam-mac/"><img src="https://img.shields.io/pypi/v/clam-mac?color=blue" alt="PyPI"></a>
  <a href="https://pypi.org/project/clam-mac/"><img src="https://img.shields.io/pypi/pyversions/clam-mac" alt="Python"></a>
  <a href="https://github.com/mileszhang001-boom/cli-on-mac/blob/main/LICENSE"><img src="https://img.shields.io/pypi/l/clam-mac" alt="License"></a>
</p>

<p align="center">
  <strong>你的 Mac 上有几十个应用，但 AI 一个都碰不到。<br>
  CLAM 自动扫描、生成 CLI 接口，让任何 AI Agent 用原生方式操控一切。</strong>
</p>

<p align="center">
  <a href="#-openclaw--lobster">OpenClaw Skill</a> · <a href="#-claude-code--mcp">Claude Code MCP</a> · <a href="#-30-秒上手人类版">命令行直接用</a> · <a href="#-为什么需要-clam">对比其他方案</a>
</p>

安装后，AI Agent 能调用应用原生能力播放音乐、操作 Word、发邮件、绘制 3D 模型——无需依赖 API，无需手动写一行适配代码。

## 🆚 为什么需要 CLAM？

现有方案让 AI 控制 Mac 应用，要么**覆盖面窄**，要么**根本做不到**：

| 现有方案 | 问题 |
|---------|------|
| 手写专用接口 (MCP/Skill) | 每个应用都要有人单独开发，大多数应用根本没有 |
| 浏览器自动化 | 只能控制网页，碰不到 Music、Finder、Word 等原生应用 |
| 让 AI 直接写 AppleScript | 语法诡异，生成的脚本经常报错，输出非结构化 |

**CLAM 一次性解决了这些问题：**

| CLAM 能力 | 效果 |
|-----------|------|
| `clam scan` | AI 自动发现你 Mac 上所有可控应用 |
| `clam install <app>` | 10 秒生成完整 CLI，零配置，不需要有人提前开发 |
| `--json` 输出 | 所有命令返回结构化 JSON，AI 可直接用于推理决策 |
| `clam doctor` | 提前告诉 AI 哪些命令可靠，避免盲试出错 |

## 🦀 给你的 AI 装上虾钳

```bash
pip install clam-mac                    # 从 PyPI 安装
```

或者从源码安装：

```bash
git clone https://github.com/mileszhang001-boom/cli-on-mac.git
cd cli-on-mac
pip install -e .                        # 开发模式
```

然后根据你使用的 AI Agent 选择集成方式：

### 🦞 OpenClaw / Lobster

CLAM 生成的每个 `clam-<app>` 都是标准 shell 命令，可直接作为 Lobster pipeline step：

```yaml
# focus-mode.lobster — 一键进入专注模式
steps:
  - run: clam-music set-sound-volume 20
  - run: clam-music play
  - run: clam-finder open ~/Projects/current
```

也可以作为 OpenClaw Skill 使用——AI 自动调用 `clam scan` 发现能力、`clam install` 安装、`clam-<app>` 执行。

### 🤖 Claude Code / MCP

```bash
claude mcp add clam -- clam-mcp         # 注册 MCP Server
```

完成了。去 Claude Code 里说一句：

> "帮我把音量调到 50，然后告诉我现在在放什么歌"

AI 自动扫描应用、安装 wrapper、执行命令——你不需要输入任何命令。

### 🔧 其他 Agent 框架

CLAM 生成的是标准 CLI 工具，任何能调用 shell 命令的 Agent 都可以直接使用：

```python
# LangChain / AutoGPT / 任何框架
import subprocess, json
result = subprocess.run(["clam-music", "--json", "get-current-track"], capture_output=True, text=True)
track = json.loads(result.stdout)  # 结构化 JSON，直接用
```

## 🖐 30 秒上手（人类版）

```bash
clam scan                              # 看看有什么
clam install music                     # 装一个试试
clam-music play                        # 播放
clam-music next-track                  # 下一首
clam-music set-sound-volume 80         # 音量 80
clam-music get-current-track           # 当前曲目全部信息（JSON）
```

## 🔧 三种模式，覆盖几乎所有应用

| 模式 | 原理 | 典型应用 | 能力 |
|------|------|---------|------|
| **完整模式** | 解析 .sdef 脚本定义 | Music、Finder、Chrome、Word | 几十到几百个命令 |
| **UI Scripting** | 通过菜单点击控制 | Figma、Slack、VS Code、Spotify | 菜单里能点的都能控 |
| **基础模式** | macOS 标准套件 | 钉钉、WPS、微信 | 前置/退出/打开文件/查版本 |

安装时自动选择最佳模式，不需要手动指定。

## 📖 完整命令

```bash
clam scan              # 扫描可控应用
clam install <app>     # 安装 CLI（模糊匹配：chrome → google-chrome）
clam info <app>        # 查看能力详情
clam doctor <app>      # 检查命令可靠性
clam list              # 查看已安装
clam remove <app>      # 卸载

clam-<app>             # 能力概览
clam-<app> api         # 完整 API 列表
```

## ❓ 常见问题

**找不到 `clam-xxx`？** — `source .venv/bin/activate`

**"permission denied"？** — 系统设置 > 隐私与安全性 > 自动化，允许终端控制目标应用

**UI Scripting 报权限错误？** — 系统设置 > 隐私与安全性 > 辅助功能，允许终端

**只有基础命令？** — 取决于应用开发者，国产应用大多没有脚本化接口，只能基础模式


path: path/to/venv/lib/python3.14/site-packages/build-1.4.0.dist-info/entry_points.txt
content: [console_scripts]
pyproject-build=build.__main__:entrypoint

[pipx.run]
build=build.__main__:entrypoint



path: path/to/venv/lib/python3.14/site-packages/certifi/__init__.py
content: from .core import contents, where

__all__ = ["contents", "where"]
__version__ = "2026.02.25"


path: path/to/venv/lib/python3.14/site-packages/certifi/__main__.py
content: import argparse

from certifi import contents, where

parser = argparse.ArgumentParser()
parser.add_argument("-c", "--contents", action="store_true")
args = parser.parse_args()

if args.contents:
    print(contents())
else:
    print(where())


path: path/to/venv/lib/python3.14/site-packages/certifi/core.py
content: """
certifi.py
~~~~~~~~~~

This module returns the installation location of cacert.pem or its contents.
"""
import sys
import atexit

def exit_cacert_ctx() -> None:
    _CACERT_CTX.__exit__(None, None, None)  # type: ignore[union-attr]


if sys.version_info >= (3, 11):

    from importlib.resources import as_file, files

    _CACERT_CTX = None
    _CACERT_PATH = None

    def where() -> str:
        # This is slightly terrible, but we want to delay extracting the file
        # in cases where we're inside of a zipimport situation until someone
        # actually calls where(), but we don't want to re-extract the file
        # on every call of where(), so we'll do it once then store it in a
        # global variable.
        global _CACERT_CTX
        global _CACERT_PATH
        if _CACERT_PATH is None:
            # This is slightly janky, the importlib.resources API wants you to
            # manage the cleanup of this file, so it doesn't actually return a
            # path, it returns a context manager that will give you the path
            # when you enter it and will do any cleanup when you leave it. In
            # the common case of not needing a temporary file, it will just
            # return the file system location and the __exit__() is a no-op.
            #
            # We also have to hold onto the actual context manager, because
            # it will do the cleanup whenever it gets garbage collected, so
            # we will also store that at the global level as well.
            _CACERT_CTX = as_file(files("certifi").joinpath("cacert.pem"))
            _CACERT_PATH = str(_CACERT_CTX.__enter__())
            atexit.register(exit_cacert_ctx)

        return _CACERT_PATH

    def contents() -> str:
        return files("certifi").joinpath("cacert.pem").read_text(encoding="ascii")

else:

    from importlib.resources import path as get_path, read_text

    _CACERT_CTX = None
    _CACERT_PATH = None

    def where() -> str:
        # This is slightly terrible, but we want to delay extracting the
        # file in cases where we're inside of a zipimport situation until
        # someone actually calls where(), but we don't want to re-extract
        # the file on every call of where(), so we'll do it once then store
        # it in a global variable.
        global _CACERT_CTX
        global _CACERT_PATH
        if _CACERT_PATH is None:
            # This is slightly janky, the importlib.resources API wants you
            # to manage the cleanup of this file, so it doesn't actually
            # return a path, it returns a context manager that will give
            # you the path when you enter it and will do any cleanup when
            # you leave it. In the common case of not needing a temporary
            # file, it will just return the file system location and the
            # __exit__() is a no-op.
            #
            # We also have to hold onto the actual context manager, because
            # it will do the cleanup whenever it gets garbage collected, so
            # we will also store that at the global level as well.
            _CACERT_CTX = get_path("certifi", "cacert.pem")
            _CACERT_PATH = str(_CACERT_CTX.__enter__())
            atexit.register(exit_cacert_ctx)

        return _CACERT_PATH

    def contents() -> str:
        return read_text("certifi", "cacert.pem", encoding="ascii")


path: path/to/venv/lib/python3.14/site-packages/certifi-2026.2.25.dist-info/top_level.txt
content: certifi


path: path/to/venv/lib/python3.14/site-packages/charset_normalizer/__init__.py
content: """
Charset-Normalizer
~~~~~~~~~~~~~~
The Real First Universal Charset Detector.
A library that helps you read text from an unknown charset encoding.
Motivated by chardet, This package is trying to resolve the issue by taking a new approach.
All IANA character set names for which the Python core library provides codecs are supported.

Basic usage:
   >>> from charset_normalizer import from_bytes
   >>> results = from_bytes('Bсеки човек има право на образование. Oбразованието!'.encode('utf_8'))
   >>> best_guess = results.best()
   >>> str(best_guess)
   'Bсеки човек има право на образование. Oбразованието!'

Others methods and usages are available - see the full documentation
at <https://github.com/Ousret/charset_normalizer>.
:copyright: (c) 2021 by Ahmed TAHRI
:license: MIT, see LICENSE for more details.
"""

from __future__ import annotations

import logging

from .api import from_bytes, from_fp, from_path, is_binary
from .legacy import detect
from .models import CharsetMatch, CharsetMatches
from .utils import set_logging_handler
from .version import VERSION, __version__

__all__ = (
    "from_fp",
    "from_path",
    "from_bytes",
    "is_binary",
    "detect",
    "CharsetMatch",
    "CharsetMatches",
    "__version__",
    "VERSION",
    "set_logging_handler",
)

# Attach a NullHandler to the top level logger by default
# https://docs.python.org/3.3/howto/logging.html#configuring-logging-for-a-library

logging.getLogger("charset_normalizer").addHandler(logging.NullHandler())


path: path/to/venv/lib/python3.14/site-packages/charset_normalizer/__main__.py
content: from __future__ import annotations

from .cli import cli_detect

if __name__ == "__main__":
    cli_detect()


path: path/to/venv/lib/python3.14/site-packages/charset_normalizer/api.py
content: from __future__ import annotations

import logging
from os import PathLike
from typing import BinaryIO

from .cd import (
    coherence_ratio,
    encoding_languages,
    mb_encoding_languages,
    merge_coherence_ratios,
)
from .constant import (
    IANA_SUPPORTED,
    IANA_SUPPORTED_SIMILAR,
    TOO_BIG_SEQUENCE,
    TOO_SMALL_SEQUENCE,
    TRACE,
)
from .md import mess_ratio
from .models import CharsetMatch, CharsetMatches
from .utils import (
    any_specified_encoding,
    cut_sequence_chunks,
    iana_name,
    identify_sig_or_bom,
    is_multi_byte_encoding,
    should_strip_sig_or_bom,
)

logger = logging.getLogger("charset_normalizer")
explain_handler = logging.StreamHandler()
explain_handler.setFormatter(
    logging.Formatter("%(asctime)s | %(levelname)s | %(message)s")
)


def from_bytes(
    sequences: bytes | bytearray,
    steps: int = 5,
    chunk_size: int = 512,
    threshold: float = 0.2,
    cp_isolation: list[str] | None = None,
    cp_exclusion: list[str] | None = None,
    preemptive_behaviour: bool = True,
    explain: bool = False,
    language_threshold: float = 0.1,
    enable_fallback: bool = True,
) -> CharsetMatches:
    """
    Given a raw bytes sequence, return the best possibles charset usable to render str objects.
    If there is no results, it is a strong indicator that the source is binary/not text.
    By default, the process will extract 5 blocks of 512o each to assess the mess and coherence of a given sequence.
    And will give up a particular code page after 20% of measured mess. Those criteria are customizable at will.

    The preemptive behavior DOES NOT replace the traditional detection workflow, it prioritize a particular code page
    but never take it for granted. Can improve the performance.

    You may want to focus your attention to some code page or/and not others, use cp_isolation and cp_exclusion for that
    purpose.

    This function will strip the SIG in the payload/sequence every time except on UTF-16, UTF-32.
    By default the library does not setup any handler other than the NullHandler, if you choose to set the 'explain'
    toggle to True it will alter the logger configuration to add a StreamHandler that is suitable for debugging.
    Custom logging format and handler can be set manually.
    """

    if not isinstance(sequences, (bytearray, bytes)):
        raise TypeError(
            "Expected object of type bytes or bytearray, got: {}".format(
                type(sequences)
            )
        )

    if explain:
        previous_logger_level: int = logger.level
        logger.addHandler(explain_handler)
        logger.setLevel(TRACE)

    length: int = len(sequences)

    if length == 0:
        logger.debug("Encoding detection on empty bytes, assuming utf_8 intention.")
        if explain:  # Defensive: ensure exit path clean handler
            logger.removeHandler(explain_handler)
            logger.setLevel(previous_logger_level)
        return CharsetMatches([CharsetMatch(sequences, "utf_8", 0.0, False, [], "")])

    if cp_isolation is not None:
        logger.log(
            TRACE,
            "cp_isolation is set. use this flag for debugging purpose. "
            "limited list of encoding allowed : %s.",
            ", ".join(cp_isolation),
        )
        cp_isolation = [iana_name(cp, False) for cp in cp_isolation]
    else:
        cp_isolation = []

    if cp_exclusion is not None:
        logger.log(
            TRACE,
            "cp_exclusion is set. use this flag for debugging purpose. "
            "limited list of encoding excluded : %s.",
            ", ".join(cp_exclusion),
        )
        cp_exclusion = [iana_name(cp, False) for cp in cp_exclusion]
    else:
        cp_exclusion = []

    if length <= (chunk_size * steps):
        logger.log(
            TRACE,
            "override steps (%i) and chunk_size (%i) as content does not fit (%i byte(s) given) parameters.",
            steps,
            chunk_size,
            length,
        )
        steps = 1
        chunk_size = length

    if steps > 1 and length / steps < chunk_size:
        chunk_size = int(length / steps)

    is_too_small_sequence: bool = len(sequences) < TOO_SMALL_SEQUENCE
    is_too_large_sequence: bool = len(sequences) >= TOO_BIG_SEQUENCE

    if is_too_small_sequence:
        logger.log(
            TRACE,
            "Trying to detect encoding from a tiny portion of ({}) byte(s).".format(
                length
            ),
        )
    elif is_too_large_sequence:
        logger.log(
            TRACE,
            "Using lazy str decoding because the payload is quite large, ({}) byte(s).".format(
                length
            ),
        )

    prioritized_encodings: list[str] = []

    specified_encoding: str | None = (
        any_specified_encoding(sequences) if preemptive_behaviour else None
    )

    if specified_encoding is not None:
        prioritized_encodings.append(specified_encoding)
        logger.log(
            TRACE,
            "Detected declarative mark in sequence. Priority +1 given for %s.",
            specified_encoding,
        )

    tested: set[str] = set()
    tested_but_hard_failure: list[str] = []
    tested_but_soft_failure: list[str] = []
    soft_failure_skip: set[str] = set()

    fallback_ascii: CharsetMatch | None = None
    fallback_u8: CharsetMatch | None = None
    fallback_specified: CharsetMatch | None = None

    results: CharsetMatches = CharsetMatches()

    early_stop_results: CharsetMatches = CharsetMatches()

    sig_encoding, sig_payload = identify_sig_or_bom(sequences)

    if sig_encoding is not None:
        prioritized_encodings.append(sig_encoding)
        logger.log(
            TRACE,
            "Detected a SIG or BOM mark on first %i byte(s). Priority +1 given for %s.",
            len(sig_payload),
            sig_encoding,
        )

    prioritized_encodings.append("ascii")

    if "utf_8" not in prioritized_encodings:
        prioritized_encodings.append("utf_8")

    for encoding_iana in prioritized_encodings + IANA_SUPPORTED:
        if cp_isolation and encoding_iana not in cp_isolation:
            continue

        if cp_exclusion and encoding_iana in cp_exclusion:
            continue

        if encoding_iana in tested:
            continue

        tested.add(encoding_iana)

        decoded_payload: str | None = None
        bom_or_sig_available: bool = sig_encoding == encoding_iana
        strip_sig_or_bom: bool = bom_or_sig_available and should_strip_sig_or_bom(
            encoding_iana
        )

        if encoding_iana in {"utf_16", "utf_32"} and not bom_or_sig_available:
            logger.log(
                TRACE,
                "Encoding %s won't be tested as-is because it require a BOM. Will try some sub-encoder LE/BE.",
                encoding_iana,
            )
            continue
        if encoding_iana in {"utf_7"} and not bom_or_sig_available:
            logger.log(
                TRACE,
                "Encoding %s won't be tested as-is because detection is unreliable without BOM/SIG.",
                encoding_iana,
            )
            continue

        # Skip encodings similar to ones that already soft-failed (high mess ratio).
        # Checked BEFORE the expensive decode attempt.
        if encoding_iana in soft_failure_skip:
            logger.log(
                TRACE,
                "%s is deemed too similar to a code page that was already considered unsuited. Continuing!",
                encoding_iana,
            )
            continue

        try:
            is_multi_byte_decoder: bool = is_multi_byte_encoding(encoding_iana)
        except (ModuleNotFoundError, ImportError):
            logger.log(
                TRACE,
                "Encoding %s does not provide an IncrementalDecoder",
                encoding_iana,
            )
            continue

        try:
            if is_too_large_sequence and is_multi_byte_decoder is False:
                str(
                    (
                        sequences[: int(50e4)]
                        if strip_sig_or_bom is False
                        else sequences[len(sig_payload) : int(50e4)]
                    ),
                    encoding=encoding_iana,
                )
            else:
                decoded_payload = str(
                    (
                        sequences
                        if strip_sig_or_bom is False
                        else sequences[len(sig_payload) :]
                    ),
                    encoding=encoding_iana,
                )
        except (UnicodeDecodeError, LookupError) as e:
            if not isinstance(e, LookupError):
                logger.log(
                    TRACE,
                    "Code page %s does not fit given bytes sequence at ALL. %s",
                    encoding_iana,
                    str(e),
                )
            tested_but_hard_failure.append(encoding_iana)
            continue

        r_ = range(
            0 if not bom_or_sig_available else len(sig_payload),
            length,
            int(length / steps),
        )

        multi_byte_bonus: bool = (
            is_multi_byte_decoder
            and decoded_payload is not None
            and len(decoded_payload) < length
        )

        if multi_byte_bonus:
            logger.log(
                TRACE,
                "Code page %s is a multi byte encoding table and it appear that at least one character "
                "was encoded using n-bytes.",
                encoding_iana,
            )

        max_chunk_gave_up: int = int(len(r_) / 4)

        max_chunk_gave_up = max(max_chunk_gave_up, 2)
        early_stop_count: int = 0
        lazy_str_hard_failure = False

        md_chunks: list[str] = []
        md_ratios = []

        try:
            for chunk in cut_sequence_chunks(
                sequences,
                encoding_iana,
                r_,
                chunk_size,
                bom_or_sig_available,
                strip_sig_or_bom,
                sig_payload,
                is_multi_byte_decoder,
                decoded_payload,
            ):
                md_chunks.append(chunk)

                md_ratios.append(
                    mess_ratio(
                        chunk,
                        threshold,
                        explain is True and 1 <= len(cp_isolation) <= 2,
                    )
                )

                if md_ratios[-1] >= threshold:
                    early_stop_count += 1

                if (early_stop_count >= max_chunk_gave_up) or (
                    bom_or_sig_available and strip_sig_or_bom is False
                ):
                    break
        except (
            UnicodeDecodeError
        ) as e:  # Lazy str loading may have missed something there
            logger.log(
                TRACE,
                "LazyStr Loading: After MD chunk decode, code page %s does not fit given bytes sequence at ALL. %s",
                encoding_iana,
                str(e),
            )
            early_stop_count = max_chunk_gave_up
            lazy_str_hard_failure = True

        # We might want to check the sequence again with the whole content
        # Only if initial MD tests passes
        if (
            not lazy_str_hard_failure
            and is_too_large_sequence
            and not is_multi_byte_decoder
        ):
            try:
                sequences[int(50e3) :].decode(encoding_iana, errors="strict")
            except UnicodeDecodeError as e:
                logger.log(
                    TRACE,
                    "LazyStr Loading: After final lookup, code page %s does not fit given bytes sequence at ALL. %s",
                    encoding_iana,
                    str(e),
                )
                tested_but_hard_failure.append(encoding_iana)
                continue

        mean_mess_ratio: float = sum(md_ratios) / len(md_ratios) if md_ratios else 0.0
        if mean_mess_ratio >= threshold or early_stop_count >= max_chunk_gave_up:
            tested_but_soft_failure.append(encoding_iana)
            if encoding_iana in IANA_SUPPORTED_SIMILAR:
                soft_failure_skip.update(IANA_SUPPORTED_SIMILAR[encoding_iana])
            logger.log(
                TRACE,
                "%s was excluded because of initial chaos probing. Gave up %i time(s). "
                "Computed mean chaos is %f %%.",
                encoding_iana,
                early_stop_count,
                round(mean_mess_ratio * 100, ndigits=3),
            )
            # Preparing those fallbacks in case we got nothing.
            if (
                enable_fallback
                and encoding_iana
                in ["ascii", "utf_8", specified_encoding, "utf_16", "utf_32"]
                and not lazy_str_hard_failure
            ):
                fallback_entry = CharsetMatch(
                    sequences,
                    encoding_iana,
                    threshold,
                    bom_or_sig_available,
                    [],
                    decoded_payload,
                    preemptive_declaration=specified_encoding,
                )
                if encoding_iana == specified_encoding:
                    fallback_specified = fallback_entry
                elif encoding_iana == "ascii":
                    fallback_ascii = fallback_entry
                else:
                    fallback_u8 = fallback_entry
            continue

        logger.log(
            TRACE,
            "%s passed initial chaos probing. Mean measured chaos is %f %%",
            encoding_iana,
            round(mean_mess_ratio * 100, ndigits=3),
        )

        if not is_multi_byte_decoder:
            target_languages: list[str] = encoding_languages(encoding_iana)
        else:
            target_languages = mb_encoding_languages(encoding_iana)

        if target_languages:
            logger.log(
                TRACE,
                "{} should target any language(s) of {}".format(
                    encoding_iana, str(target_languages)
                ),
            )

        cd_ratios = []

        # We shall skip the CD when its about ASCII
        # Most of the time its not relevant to run "language-detection" on it.
        if encoding_iana != "ascii":
            for chunk in md_chunks:
                chunk_languages = coherence_ratio(
                    chunk,
                    language_threshold,
                    ",".join(target_languages) if target_languages else None,
                )

                cd_ratios.append(chunk_languages)

        cd_ratios_merged = merge_coherence_ratios(cd_ratios)

        if cd_ratios_merged:
            logger.log(
                TRACE,
                "We detected language {} using {}".format(
                    cd_ratios_merged, encoding_iana
                ),
            )

        current_match = CharsetMatch(
            sequences,
            encoding_iana,
            mean_mess_ratio,
            bom_or_sig_available,
            cd_ratios_merged,
            (
                decoded_payload
                if (
                    is_too_large_sequence is False
                    or encoding_iana in [specified_encoding, "ascii", "utf_8"]
                )
                else None
            ),
            preemptive_declaration=specified_encoding,
        )

        results.append(current_match)

        if (
            encoding_iana in [specified_encoding, "ascii", "utf_8"]
            and mean_mess_ratio < 0.1
        ):
            # If md says nothing to worry about, then... stop immediately!
            if mean_mess_ratio == 0.0:
                logger.debug(
                    "Encoding detection: %s is most likely the one.",
                    current_match.encoding,
                )
                if explain:  # Defensive: ensure exit path clean handler
                    logger.removeHandler(explain_handler)
                    logger.setLevel(previous_logger_level)
                return CharsetMatches([current_match])

            early_stop_results.append(current_match)

        if (
            len(early_stop_results)
            and (specified_encoding is None or specified_encoding in tested)
            and "ascii" in tested
            and "utf_8" in tested
        ):
            probable_result: CharsetMatch = early_stop_results.best()  # type: ignore[assignment]
            logger.debug(
                "Encoding detection: %s is most likely the one.",
                probable_result.encoding,
            )
            if explain:  # Defensive: ensure exit path clean handler
                logger.removeHandler(explain_handler)
                logger.setLevel(previous_logger_level)

            return CharsetMatches([probable_result])

        if encoding_iana == sig_encoding:
            logger.debug(
                "Encoding detection: %s is most likely the one as we detected a BOM or SIG within "
                "the beginning of the sequence.",
                encoding_iana,
            )
            if explain:  # Defensive: ensure exit path clean handler
                logger.removeHandler(explain_handler)
                logger.setLevel(previous_logger_level)
            return CharsetMatches([results[encoding_iana]])

    if len(results) == 0:
        if fallback_u8 or fallback_ascii or fallback_specified:
            logger.log(
                TRACE,
                "Nothing got out of the detection process. Using ASCII/UTF-8/Specified fallback.",
            )

        if fallback_specified:
            logger.debug(
                "Encoding detection: %s will be used as a fallback match",
                fallback_specified.encoding,
            )
            results.append(fallback_specified)
        elif (
            (fallback_u8 and fallback_ascii is None)
            or (
                fallback_u8
                and fallback_ascii
                and fallback_u8.fingerprint != fallback_ascii.fingerprint
            )
            or (fallback_u8 is not None)
        ):
            logger.debug("Encoding detection: utf_8 will be used as a fallback match")
            results.append(fallback_u8)
        elif fallback_ascii:
            logger.debug("Encoding detection: ascii will be used as a fallback match")
            results.append(fallback_ascii)

    if results:
        logger.debug(
            "Encoding detection: Found %s as plausible (best-candidate) for content. With %i alternatives.",
            results.best().encoding,  # type: ignore
            len(results) - 1,
        )
    else:
        logger.debug("Encoding detection: Unable to determine any suitable charset.")

    if explain:
        logger.removeHandler(explain_handler)
        logger.setLevel(previous_logger_level)

    return results


def from_fp(
    fp: BinaryIO,
    steps: int = 5,
    chunk_size: int = 512,
    threshold: float = 0.20,
    cp_isolation: list[str] | None = None,
    cp_exclusion: list[str] | None = None,
    preemptive_behaviour: bool = True,
    explain: bool = False,
    language_threshold: float = 0.1,
    enable_fallback: bool = True,
) -> CharsetMatches:
    """
    Same thing than the function from_bytes but using a file pointer that is already ready.
    Will not close the file pointer.
    """
    return from_bytes(
        fp.read(),
        steps,
        chunk_size,
        threshold,
        cp_isolation,
        cp_exclusion,
        preemptive_behaviour,
        explain,
        language_threshold,
        enable_fallback,
    )


def from_path(
    path: str | bytes | PathLike,  # type: ignore[type-arg]
    steps: int = 5,
    chunk_size: int = 512,
    threshold: float = 0.20,
    cp_isolation: list[str] | None = None,
    cp_exclusion: list[str] | None = None,
    preemptive_behaviour: bool = True,
    explain: bool = False,
    language_threshold: float = 0.1,
    enable_fallback: bool = True,
) -> CharsetMatches:
    """
    Same thing than the function from_bytes but with one extra step. Opening and reading given file path in binary mode.
    Can raise IOError.
    """
    with open(path, "rb") as fp:
        return from_fp(
            fp,
            steps,
            chunk_size,
            threshold,
            cp_isolation,
            cp_exclusion,
            preemptive_behaviour,
            explain,
            language_threshold,
            enable_fallback,
        )


def is_binary(
    fp_or_path_or_payload: PathLike | str | BinaryIO | bytes,  # type: ignore[type-arg]
    steps: int = 5,
    chunk_size: int = 512,
    threshold: float = 0.20,
    cp_isolation: list[str] | None = None,
    cp_exclusion: list[str] | None = None,
    preemptive_behaviour: bool = True,
    explain: bool = False,
    language_threshold: float = 0.1,
    enable_fallback: bool = False,
) -> bool:
    """
    Detect if the given input (file, bytes, or path) points to a binary file. aka. not a string.
    Based on the same main heuristic algorithms and default kwargs at the sole exception that fallbacks match
    are disabled to be stricter around ASCII-compatible but unlikely to be a string.
    """
    if isinstance(fp_or_path_or_payload, (str, PathLike)):
        guesses = from_path(
            fp_or_path_or_payload,
            steps=steps,
            chunk_size=chunk_size,
            threshold=threshold,
            cp_isolation=cp_isolation,
            cp_exclusion=cp_exclusion,
            preemptive_behaviour=preemptive_behaviour,
            explain=explain,
            language_threshold=language_threshold,
            enable_fallback=enable_fallback,
        )
    elif isinstance(
        fp_or_path_or_payload,
        (
            bytes,
            bytearray,
        ),
    ):
        guesses = from_bytes(
            fp_or_path_or_payload,
            steps=steps,
            chunk_size=chunk_size,
            threshold=threshold,
            cp_isolation=cp_isolation,
            cp_exclusion=cp_exclusion,
            preemptive_behaviour=preemptive_behaviour,
            explain=explain,
            language_threshold=language_threshold,
            enable_fallback=enable_fallback,
        )
    else:
        guesses = from_fp(
            fp_or_path_or_payload,
            steps=steps,
            chunk_size=chunk_size,
            threshold=threshold,
            cp_isolation=cp_isolation,
            cp_exclusion=cp_exclusion,
            preemptive_behaviour=preemptive_behaviour,
            explain=explain,
            language_threshold=language_threshold,
            enable_fallback=enable_fallback,
        )

    return not guesses


path: path/to/venv/lib/python3.14/site-packages/charset_normalizer/cd.py
content: from __future__ import annotations

import importlib
from codecs import IncrementalDecoder
from collections import Counter
from functools import lru_cache
from typing import Counter as TypeCounter

from .constant import (
    FREQUENCIES,
    KO_NAMES,
    LANGUAGE_SUPPORTED_COUNT,
    TOO_SMALL_SEQUENCE,
    ZH_NAMES,
    _FREQUENCIES_SET,
    _FREQUENCIES_RANK,
)
from .md import is_suspiciously_successive_range
from .models import CoherenceMatches
from .utils import (
    is_accentuated,
    is_latin,
    is_multi_byte_encoding,
    is_unicode_range_secondary,
    unicode_range,
)


def encoding_unicode_range(iana_name: str) -> list[str]:
    """
    Return associated unicode ranges in a single byte code page.
    """
    if is_multi_byte_encoding(iana_name):
        raise OSError("Function not supported on multi-byte code page")

    decoder = importlib.import_module(f"encodings.{iana_name}").IncrementalDecoder

    p: IncrementalDecoder = decoder(errors="ignore")
    seen_ranges: dict[str, int] = {}
    character_count: int = 0

    for i in range(0x40, 0xFF):
        chunk: str = p.decode(bytes([i]))

        if chunk:
            character_range: str | None = unicode_range(chunk)

            if character_range is None:
                continue

            if is_unicode_range_secondary(character_range) is False:
                if character_range not in seen_ranges:
                    seen_ranges[character_range] = 0
                seen_ranges[character_range] += 1
                character_count += 1

    return sorted(
        [
            character_range
            for character_range in seen_ranges
            if seen_ranges[character_range] / character_count >= 0.15
        ]
    )


def unicode_range_languages(primary_range: str) -> list[str]:
    """
    Return inferred languages used with a unicode range.
    """
    languages: list[str] = []

    for language, characters in FREQUENCIES.items():
        for character in characters:
            if unicode_range(character) == primary_range:
                languages.append(language)
                break

    return languages


@lru_cache()
def encoding_languages(iana_name: str) -> list[str]:
    """
    Single-byte encoding language association. Some code page are heavily linked to particular language(s).
    This function does the correspondence.
    """
    unicode_ranges: list[str] = encoding_unicode_range(iana_name)
    primary_range: str | None = None

    for specified_range in unicode_ranges:
        if "Latin" not in specified_range:
            primary_range = specified_range
            break

    if primary_range is None:
        return ["Latin Based"]

    return unicode_range_languages(primary_range)


@lru_cache()
def mb_encoding_languages(iana_name: str) -> list[str]:
    """
    Multi-byte encoding language association. Some code page are heavily linked to particular language(s).
    This function does the correspondence.
    """
    if (
        iana_name.startswith("shift_")
        or iana_name.startswith("iso2022_jp")
        or iana_name.startswith("euc_j")
        or iana_name == "cp932"
    ):
        return ["Japanese"]
    if iana_name.startswith("gb") or iana_name in ZH_NAMES:
        return ["Chinese"]
    if iana_name.startswith("iso2022_kr") or iana_name in KO_NAMES:
        return ["Korean"]

    return []


@lru_cache(maxsize=LANGUAGE_SUPPORTED_COUNT)
def get_target_features(language: str) -> tuple[bool, bool]:
    """
    Determine main aspects from a supported language if it contains accents and if is pure Latin.
    """
    target_have_accents: bool = False
    target_pure_latin: bool = True

    for character in FREQUENCIES[language]:
        if not target_have_accents and is_accentuated(character):
            target_have_accents = True
        if target_pure_latin and is_latin(character) is False:
            target_pure_latin = False

    return target_have_accents, target_pure_latin


def alphabet_languages(
    characters: list[str], ignore_non_latin: bool = False
) -> list[str]:
    """
    Return associated languages associated to given characters.
    """
    languages: list[tuple[str, float]] = []

    characters_set: frozenset[str] = frozenset(characters)
    source_have_accents = any(is_accentuated(character) for character in characters)

    for language, language_characters in FREQUENCIES.items():
        target_have_accents, target_pure_latin = get_target_features(language)

        if ignore_non_latin and target_pure_latin is False:
            continue

        if target_have_accents is False and source_have_accents:
            continue

        character_count: int = len(language_characters)

        character_match_count: int = len(_FREQUENCIES_SET[language] & characters_set)

        ratio: float = character_match_count / character_count

        if ratio >= 0.2:
            languages.append((language, ratio))

    languages = sorted(languages, key=lambda x: x[1], reverse=True)

    return [compatible_language[0] for compatible_language in languages]


def characters_popularity_compare(
    language: str, ordered_characters: list[str]
) -> float:
    """
    Determine if a ordered characters list (by occurrence from most appearance to rarest) match a particular language.
    The result is a ratio between 0. (absolutely no correspondence) and 1. (near perfect fit).
    Beware that is function is not strict on the match in order to ease the detection. (Meaning close match is 1.)
    """
    if language not in FREQUENCIES:
        raise ValueError(f"{language} not available")

    character_approved_count: int = 0
    frequencies_language_set: frozenset[str] = _FREQUENCIES_SET[language]
    lang_rank: dict[str, int] = _FREQUENCIES_RANK[language]

    ordered_characters_count: int = len(ordered_characters)
    target_language_characters_count: int = len(FREQUENCIES[language])

    large_alphabet: bool = target_language_characters_count > 26

    expected_projection_ratio: float = (
        target_language_characters_count / ordered_characters_count
    )

    # Pre-built rank dict for ordered_characters (avoids repeated list slicing).
    ordered_rank: dict[str, int] = {
        char: rank for rank, char in enumerate(ordered_characters)
    }

    # Pre-compute characters common to both orderings.
    # Avoids repeated `c in ordered_rank` dict lookups in the inner counts.
    common_chars: list[tuple[int, int]] = [
        (lr, ordered_rank[c]) for c, lr in lang_rank.items() if c in ordered_rank
    ]

    for character, character_rank in zip(
        ordered_characters, range(0, ordered_characters_count)
    ):
        if character not in frequencies_language_set:
            continue

        character_rank_in_language: int = lang_rank[character]
        character_rank_projection: int = int(character_rank * expected_projection_ratio)

        if (
            large_alphabet is False
            and abs(character_rank_projection - character_rank_in_language) > 4
        ):
            continue

        if (
            large_alphabet is True
            and abs(character_rank_projection - character_rank_in_language)
            < target_language_characters_count / 3
        ):
            character_approved_count += 1
            continue

        # Count how many characters appear "before" in both orderings,
        # and how many appear "at or after" in both orderings.
        before_match_count: int = sum(
            1
            for lr, orr in common_chars
            if lr < character_rank_in_language and orr < character_rank
        )

        after_len: int = target_language_characters_count - character_rank_in_language
        after_match_count: int = sum(
            1
            for lr, orr in common_chars
            if lr >= character_rank_in_language and orr >= character_rank
        )

        if character_rank_in_language == 0 and before_match_count <= 4:
            character_approved_count += 1
            continue

        if after_len == 0 and after_match_count <= 4:
            character_approved_count += 1
            continue

        if (
            character_rank_in_language > 0
            and before_match_count / character_rank_in_language >= 0.4
        ) or (after_len > 0 and after_match_count / after_len >= 0.4):
            character_approved_count += 1
            continue

    return character_approved_count / len(ordered_characters)


def alpha_unicode_split(decoded_sequence: str) -> list[str]:
    """
    Given a decoded text sequence, return a list of str. Unicode range / alphabet separation.
    Ex. a text containing English/Latin with a bit a Hebrew will return two items in the resulting list;
    One containing the latin letters and the other hebrew.
    """
    layers: dict[str, list[str]] = {}

    # Fast path: track single-layer key to skip dict iteration for single-script text.
    single_layer_key: str | None = None
    multi_layer: bool = False

    for character in decoded_sequence:
        if character.isalpha() is False:
            continue

        character_range: str | None = unicode_range(character)

        if character_range is None:
            continue

        layer_target_range: str | None = None

        if multi_layer:
            for discovered_range in layers:
                if (
                    is_suspiciously_successive_range(discovered_range, character_range)
                    is False
                ):
                    layer_target_range = discovered_range
                    break
        elif single_layer_key is not None:
            if (
                is_suspiciously_successive_range(single_layer_key, character_range)
                is False
            ):
                layer_target_range = single_layer_key

        if layer_target_range is None:
            layer_target_range = character_range

        if layer_target_range not in layers:
            layers[layer_target_range] = []
            if single_layer_key is None:
                single_layer_key = layer_target_range
            else:
                multi_layer = True

        layers[layer_target_range].append(character)

    return ["".join(chars).lower() for chars in layers.values()]


def merge_coherence_ratios(results: list[CoherenceMatches]) -> CoherenceMatches:
    """
    This function merge results previously given by the function coherence_ratio.
    The return type is the same as coherence_ratio.
    """
    per_language_ratios: dict[str, list[float]] = {}
    for result in results:
        for sub_result in result:
            language, ratio = sub_result
            if language not in per_language_ratios:
                per_language_ratios[language] = [ratio]
                continue
            per_language_ratios[language].append(ratio)

    merge = [
        (
            language,
            round(
                sum(per_language_ratios[language]) / len(per_language_ratios[language]),
                4,
            ),
        )
        for language in per_language_ratios
    ]

    return sorted(merge, key=lambda x: x[1], reverse=True)


def filter_alt_coherence_matches(results: CoherenceMatches) -> CoherenceMatches:
    """
    We shall NOT return "English—" in CoherenceMatches because it is an alternative
    of "English". This function only keeps the best match and remove the em-dash in it.
    """
    index_results: dict[str, list[float]] = dict()

    for result in results:
        language, ratio = result
        no_em_name: str = language.replace("—", "")

        if no_em_name not in index_results:
            index_results[no_em_name] = []

        index_results[no_em_name].append(ratio)

    if any(len(index_results[e]) > 1 for e in index_results):
        filtered_results: CoherenceMatches = []

        for language in index_results:
            filtered_results.append((language, max(index_results[language])))

        return filtered_results

    return results


@lru_cache(maxsize=2048)
def coherence_ratio(
    decoded_sequence: str, threshold: float = 0.1, lg_inclusion: str | None = None
) -> CoherenceMatches:
    """
    Detect ANY language that can be identified in given sequence. The sequence will be analysed by layers.
    A layer = Character extraction by alphabets/ranges.
    """

    results: list[tuple[str, float]] = []
    ignore_non_latin: bool = False

    sufficient_match_count: int = 0

    lg_inclusion_list = lg_inclusion.split(",") if lg_inclusion is not None else []
    if "Latin Based" in lg_inclusion_list:
        ignore_non_latin = True
        lg_inclusion_list.remove("Latin Based")

    for layer in alpha_unicode_split(decoded_sequence):
        sequence_frequencies: TypeCounter[str] = Counter(layer)
        most_common = sequence_frequencies.most_common()

        character_count: int = len(layer)

        if character_count <= TOO_SMALL_SEQUENCE:
            continue

        popular_character_ordered: list[str] = [c for c, o in most_common]

        for language in lg_inclusion_list or alphabet_languages(
            popular_character_ordered, ignore_non_latin
        ):
            ratio: float = characters_popularity_compare(
                language, popular_character_ordered
            )

            if ratio < threshold:
                continue
            elif ratio >= 0.8:
                sufficient_match_count += 1

            results.append((language, round(ratio, 4)))

            if sufficient_match_count >= 3:
                break

    return sorted(
        filter_alt_coherence_matches(results), key=lambda x: x[1], reverse=True
    )


path: path/to/venv/lib/python3.14/site-packages/charset_normalizer/cli/__init__.py
content: from __future__ import annotations

from .__main__ import cli_detect, query_yes_no

__all__ = (
    "cli_detect",
    "query_yes_no",
)


path: path/to/venv/lib/python3.14/site-packages/charset_normalizer/cli/__main__.py
content: from __future__ import annotations

import argparse
import sys
import typing
from json import dumps
from os.path import abspath, basename, dirname, join, realpath
from platform import python_version
from unicodedata import unidata_version

import charset_normalizer.md as md_module
from charset_normalizer import from_fp
from charset_normalizer.models import CliDetectionResult
from charset_normalizer.version import __version__


def query_yes_no(question: str, default: str = "yes") -> bool:
    """Ask a yes/no question via input() and return the answer as a bool."""
    prompt = " [Y/n] " if default == "yes" else " [y/N] "

    while True:
        choice = input(question + prompt).strip().lower()
        if not choice:
            return default == "yes"
        if choice in ("y", "yes"):
            return True
        if choice in ("n", "no"):
            return False
        print("Please respond with 'y' or 'n'.")


class FileType:
    """Factory for creating file object types

    Instances of FileType are typically passed as type= arguments to the
    ArgumentParser add_argument() method.

    Keyword Arguments:
        - mode -- A string indicating how the file is to be opened. Accepts the
            same values as the builtin open() function.
        - bufsize -- The file's desired buffer size. Accepts the same values as
            the builtin open() function.
        - encoding -- The file's encoding. Accepts the same values as the
            builtin open() function.
        - errors -- A string indicating how encoding and decoding errors are to
            be handled. Accepts the same value as the builtin open() function.

    Backported from CPython 3.12
    """

    def __init__(
        self,
        mode: str = "r",
        bufsize: int = -1,
        encoding: str | None = None,
        errors: str | None = None,
    ):
        self._mode = mode
        self._bufsize = bufsize
        self._encoding = encoding
        self._errors = errors

    def __call__(self, string: str) -> typing.IO:  # type: ignore[type-arg]
        # the special argument "-" means sys.std{in,out}
        if string == "-":
            if "r" in self._mode:
                return sys.stdin.buffer if "b" in self._mode else sys.stdin
            elif any(c in self._mode for c in "wax"):
                return sys.stdout.buffer if "b" in self._mode else sys.stdout
            else:
                msg = f'argument "-" with mode {self._mode}'
                raise ValueError(msg)

        # all other arguments are used as file names
        try:
            return open(string, self._mode, self._bufsize, self._encoding, self._errors)
        except OSError as e:
            message = f"can't open '{string}': {e}"
            raise argparse.ArgumentTypeError(message)

    def __repr__(self) -> str:
        args = self._mode, self._bufsize
        kwargs = [("encoding", self._encoding), ("errors", self._errors)]
        args_str = ", ".join(
            [repr(arg) for arg in args if arg != -1]
            + [f"{kw}={arg!r}" for kw, arg in kwargs if arg is not None]
        )
        return f"{type(self).__name__}({args_str})"


def cli_detect(argv: list[str] | None = None) -> int:
    """
    CLI assistant using ARGV and ArgumentParser
    :param argv:
    :return: 0 if everything is fine, anything else equal trouble
    """
    parser = argparse.ArgumentParser(
        description="The Real First Universal Charset Detector. "
        "Discover originating encoding used on text file. "
        "Normalize text to unicode."
    )

    parser.add_argument(
        "files", type=FileType("rb"), nargs="+", help="File(s) to be analysed"
    )
    parser.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        default=False,
        dest="verbose",
        help="Display complementary information about file if any. "
        "Stdout will contain logs about the detection process.",
    )
    parser.add_argument(
        "-a",
        "--with-alternative",
        action="store_true",
        default=False,
        dest="alternatives",
        help="Output complementary possibilities if any. Top-level JSON WILL be a list.",
    )
    parser.add_argument(
        "-n",
        "--normalize",
        action="store_true",
        default=False,
        dest="normalize",
        help="Permit to normalize input file. If not set, program does not write anything.",
    )
    parser.add_argument(
        "-m",
        "--minimal",
        action="store_true",
        default=False,
        dest="minimal",
        help="Only output the charset detected to STDOUT. Disabling JSON output.",
    )
    parser.add_argument(
        "-r",
        "--replace",
        action="store_true",
        default=False,
        dest="replace",
        help="Replace file when trying to normalize it instead of creating a new one.",
    )
    parser.add_argument(
        "-f",
        "--force",
        action="store_true",
        default=False,
        dest="force",
        help="Replace file without asking if you are sure, use this flag with caution.",
    )
    parser.add_argument(
        "-i",
        "--no-preemptive",
        action="store_true",
        default=False,
        dest="no_preemptive",
        help="Disable looking at a charset declaration to hint the detector.",
    )
    parser.add_argument(
        "-t",
        "--threshold",
        action="store",
        default=0.2,
        type=float,
        dest="threshold",
        help="Define a custom maximum amount of noise allowed in decoded content. 0. <= noise <= 1.",
    )
    parser.add_argument(
        "--version",
        action="version",
        version="Charset-Normalizer {} - Python {} - Unicode {} - SpeedUp {}".format(
            __version__,
            python_version(),
            unidata_version,
            "OFF" if md_module.__file__.lower().endswith(".py") else "ON",
        ),
        help="Show version information and exit.",
    )

    args = parser.parse_args(argv)

    if args.replace is True and args.normalize is False:
        if args.files:
            for my_file in args.files:
                my_file.close()
        print("Use --replace in addition of --normalize only.", file=sys.stderr)
        return 1

    if args.force is True and args.replace is False:
        if args.files:
            for my_file in args.files:
                my_file.close()
        print("Use --force in addition of --replace only.", file=sys.stderr)
        return 1

    if args.threshold < 0.0 or args.threshold > 1.0:
        if args.files:
            for my_file in args.files:
                my_file.close()
        print("--threshold VALUE should be between 0. AND 1.", file=sys.stderr)
        return 1

    x_ = []

    for my_file in args.files:
        matches = from_fp(
            my_file,
            threshold=args.threshold,
            explain=args.verbose,
            preemptive_behaviour=args.no_preemptive is False,
        )

        best_guess = matches.best()

        if best_guess is None:
            print(
                'Unable to identify originating encoding for "{}". {}'.format(
                    my_file.name,
                    (
                        "Maybe try increasing maximum amount of chaos."
                        if args.threshold < 1.0
                        else ""
                    ),
                ),
                file=sys.stderr,
            )
            x_.append(
                CliDetectionResult(
                    abspath(my_file.name),
                    None,
                    [],
                    [],
                    "Unknown",
                    [],
                    False,
                    1.0,
                    0.0,
                    None,
                    True,
                )
            )
        else:
            x_.append(
                CliDetectionResult(
                    abspath(my_file.name),
                    best_guess.encoding,
                    best_guess.encoding_aliases,
                    [
                        cp
                        for cp in best_guess.could_be_from_charset
                        if cp != best_guess.encoding
                    ],
                    best_guess.language,
                    best_guess.alphabets,
                    best_guess.bom,
                    best_guess.percent_chaos,
                    best_guess.percent_coherence,
                    None,
                    True,
                )
            )

            if len(matches) > 1 and args.alternatives:
                for el in matches:
                    if el != best_guess:
                        x_.append(
                            CliDetectionResult(
                                abspath(my_file.name),
                                el.encoding,
                                el.encoding_aliases,
                                [
                                    cp
                                    for cp in el.could_be_from_charset
                                    if cp != el.encoding
                                ],
                                el.language,
                                el.alphabets,
                                el.bom,
                                el.percent_chaos,
                                el.percent_coherence,
                                None,
                                False,
                            )
                        )

            if args.normalize is True:
                if best_guess.encoding.startswith("utf") is True:
                    print(
                        '"{}" file does not need to be normalized, as it already came from unicode.'.format(
                            my_file.name
                        ),
                        file=sys.stderr,
                    )
                    if my_file.closed is False:
                        my_file.close()
                    continue

                dir_path = dirname(realpath(my_file.name))
                file_name = basename(realpath(my_file.name))

                o_: list[str] = file_name.split(".")

                if args.replace is False:
                    o_.insert(-1, best_guess.encoding)
                    if my_file.closed is False:
                        my_file.close()
                elif (
                    args.force is False
                    and query_yes_no(
                        'Are you sure to normalize "{}" by replacing it ?'.format(
                            my_file.name
                        ),
                        "no",
                    )
                    is False
                ):
                    if my_file.closed is False:
                        my_file.close()
                    continue

                try:
                    x_[0].unicode_path = join(dir_path, ".".join(o_))

                    with open(x_[0].unicode_path, "wb") as fp:
                        fp.write(best_guess.output())
                except OSError as e:
                    print(str(e), file=sys.stderr)
                    if my_file.closed is False:
                        my_file.close()
                    return 2

        if my_file.closed is False:
            my_file.close()

    if args.minimal is False:
        print(
            dumps(
                [el.__dict__ for el in x_] if len(x_) > 1 else x_[0].__dict__,
                ensure_ascii=True,
                indent=4,
            )
        )
    else:
        for my_file in args.files:
            print(
                ", ".join(
                    [
                        el.encoding or "undefined"
                        for el in x_
                        if el.path == abspath(my_file.name)
                    ]
                )
            )

    return 0


if __name__ == "__main__":
    cli_detect()


path: path/to/venv/lib/python3.14/site-packages/charset_normalizer/legacy.py
content: from __future__ import annotations

from typing import TYPE_CHECKING, Any
from warnings import warn

from .api import from_bytes
from .constant import CHARDET_CORRESPONDENCE, TOO_SMALL_SEQUENCE

# TODO: remove this check when dropping Python 3.7 support
if TYPE_CHECKING:
    from typing_extensions import TypedDict

    class ResultDict(TypedDict):
        encoding: str | None
        language: str
        confidence: float | None


def detect(
    byte_str: bytes, should_rename_legacy: bool = False, **kwargs: Any
) -> ResultDict:
    """
    chardet legacy method
    Detect the encoding of the given byte string. It should be mostly backward-compatible.
    Encoding name will match Chardet own writing whenever possible. (Not on encoding name unsupported by it)
    This function is deprecated and should be used to migrate your project easily, consult the documentation for
    further information. Not planned for removal.

    :param byte_str:     The byte sequence to examine.
    :param should_rename_legacy:  Should we rename legacy encodings
                                  to their more modern equivalents?
    """
    if len(kwargs):
        warn(
            f"charset-normalizer disregard arguments '{','.join(list(kwargs.keys()))}' in legacy function detect()"
        )

    if not isinstance(byte_str, (bytearray, bytes)):
        raise TypeError(  # pragma: nocover
            f"Expected object of type bytes or bytearray, got: {type(byte_str)}"
        )

    if isinstance(byte_str, bytearray):
        byte_str = bytes(byte_str)

    r = from_bytes(byte_str).best()

    encoding = r.encoding if r is not None else None
    language = r.language if r is not None and r.language != "Unknown" else ""
    confidence = 1.0 - r.chaos if r is not None else None

    # automatically lower confidence
    # on small bytes samples.
    # https://github.com/jawah/charset_normalizer/issues/391
    if (
        confidence is not None
        and confidence >= 0.9
        and encoding
        not in {
            "utf_8",
            "ascii",
        }
        and r.bom is False  # type: ignore[union-attr]
        and len(byte_str) < TOO_SMALL_SEQUENCE
    ):
        confidence -= 0.2

    # Note: CharsetNormalizer does not return 'UTF-8-SIG' as the sig get stripped in the detection/normalization process
    # but chardet does return 'utf-8-sig' and it is a valid codec name.
    if r is not None and encoding == "utf_8" and r.bom:
        encoding += "_sig"

    if should_rename_legacy is False and encoding in CHARDET_CORRESPONDENCE:
        encoding = CHARDET_CORRESPONDENCE[encoding]

    return {
        "encoding": encoding,
        "language": language,
        "confidence": confidence,
    }


path: path/to/venv/lib/python3.14/site-packages/charset_normalizer/md.py
content: from __future__ import annotations

import sys
from functools import lru_cache
from logging import getLogger

if sys.version_info >= (3, 8):
    from typing import final
else:
    try:
        from typing_extensions import final
    except ImportError:

        def final(cls):  # type: ignore[misc,no-untyped-def]
            return cls


from .constant import (
    COMMON_SAFE_ASCII_CHARACTERS,
    TRACE,
    UNICODE_SECONDARY_RANGE_KEYWORD,
    _ACCENTUATED,
    _CJK,
    _HANGUL,
    _HIRAGANA,
    _KATAKANA,
    _LATIN,
    _THAI,
)
from .utils import (
    _character_flags,
    is_accentuated,
    is_arabic,
    is_arabic_isolated_form,
    is_case_variable,
    is_cjk,
    is_emoticon,
    is_latin,
    is_punctuation,
    is_separator,
    is_symbol,
    is_unprintable,
    remove_accent,
    unicode_range,
    is_cjk_uncommon,
)

# Combined bitmask for CJK/Hangul/Katakana/Hiragana/Thai glyph detection.
_GLYPH_MASK: int = _CJK | _HANGUL | _KATAKANA | _HIRAGANA | _THAI


class MessDetectorPlugin:
    """
    Base abstract class used for mess detection plugins.
    All detectors MUST extend and implement given methods.
    """

    __slots__ = ()

    def eligible(self, character: str) -> bool:
        """
        Determine if given character should be fed in.
        """
        raise NotImplementedError  # pragma: nocover

    def feed(self, character: str) -> None:
        """
        The main routine to be executed upon character.
        Insert the logic in witch the text would be considered chaotic.
        """
        raise NotImplementedError  # pragma: nocover

    def reset(self) -> None:  # pragma: no cover
        """
        Permit to reset the plugin to the initial state.
        """
        raise NotImplementedError

    @property
    def ratio(self) -> float:
        """
        Compute the chaos ratio based on what your feed() has seen.
        Must NOT be lower than 0.; No restriction gt 0.
        """
        raise NotImplementedError  # pragma: nocover


@final
class TooManySymbolOrPunctuationPlugin(MessDetectorPlugin):
    __slots__ = (
        "_punctuation_count",
        "_symbol_count",
        "_character_count",
        "_last_printable_char",
        "_frenzy_symbol_in_word",
    )

    def __init__(self) -> None:
        self._punctuation_count: int = 0
        self._symbol_count: int = 0
        self._character_count: int = 0

        self._last_printable_char: str | None = None
        self._frenzy_symbol_in_word: bool = False

    def eligible(self, character: str) -> bool:
        return character.isprintable()

    def feed(self, character: str) -> None:
        self._character_count += 1

        if (
            character != self._last_printable_char
            and character not in COMMON_SAFE_ASCII_CHARACTERS
        ):
            if is_punctuation(character):
                self._punctuation_count += 1
            elif (
                not character.isdigit()
                and is_symbol(character)
                and not is_emoticon(character)
            ):
                self._symbol_count += 2

        self._last_printable_char = character

    def reset(self) -> None:  # Abstract
        self._punctuation_count = 0
        self._character_count = 0
        self._symbol_count = 0

    @property
    def ratio(self) -> float:
        if self._character_count == 0:
            return 0.0

        ratio_of_punctuation: float = (
            self._punctuation_count + self._symbol_count
        ) / self._character_count

        return ratio_of_punctuation if ratio_of_punctuation >= 0.3 else 0.0


@final
class TooManyAccentuatedPlugin(MessDetectorPlugin):
    __slots__ = ("_character_count", "_accentuated_count")

    def __init__(self) -> None:
        self._character_count: int = 0
        self._accentuated_count: int = 0

    def eligible(self, character: str) -> bool:
        return character.isalpha()

    def feed(self, character: str) -> None:
        self._character_count += 1

        if is_accentuated(character):
            self._accentuated_count += 1

    def reset(self) -> None:  # Abstract
        self._character_count = 0
        self._accentuated_count = 0

    @property
    def ratio(self) -> float:
        if self._character_count < 8:
            return 0.0

        ratio_of_accentuation: float = self._accentuated_count / self._character_count
        return ratio_of_accentuation if ratio_of_accentuation >= 0.35 else 0.0


@final
class UnprintablePlugin(MessDetectorPlugin):
    __slots__ = ("_unprintable_count", "_character_count")

    def __init__(self) -> None:
        self._unprintable_count: int = 0
        self._character_count: int = 0

    def eligible(self, character: str) -> bool:
        return True

    def feed(self, character: str) -> None:
        if is_unprintable(character):
            self._unprintable_count += 1
        self._character_count += 1

    def reset(self) -> None:  # Abstract
        self._unprintable_count = 0

    @property
    def ratio(self) -> float:
        if self._character_count == 0:
            return 0.0

        return (self._unprintable_count * 8) / self._character_count


@final
class SuspiciousDuplicateAccentPlugin(MessDetectorPlugin):
    __slots__ = (
        "_successive_count",
        "_character_count",
        "_last_latin_character",
        "_last_was_accentuated",
    )

    def __init__(self) -> None:
        self._successive_count: int = 0
        self._character_count: int = 0

        self._last_latin_character: str | None = None
        self._last_was_accentuated: bool = False

    def eligible(self, character: str) -> bool:
        return character.isalpha() and is_latin(character)

    def feed(self, character: str) -> None:
        self._character_count += 1
        current_accentuated: bool = is_accentuated(character)
        if (
            self._last_latin_character is not None
            and current_accentuated
            and self._last_was_accentuated
        ):
            if character.isupper() and self._last_latin_character.isupper():
                self._successive_count += 1
            # Worse if its the same char duplicated with different accent.
            if remove_accent(character) == remove_accent(self._last_latin_character):
                self._successive_count += 1
        self._last_latin_character = character
        self._last_was_accentuated = current_accentuated

    def reset(self) -> None:  # Abstract
        self._successive_count = 0
        self._character_count = 0
        self._last_latin_character = None
        self._last_was_accentuated = False

    @property
    def ratio(self) -> float:
        if self._character_count == 0:
            return 0.0

        return (self._successive_count * 2) / self._character_count


@final
class SuspiciousRange(MessDetectorPlugin):
    __slots__ = (
        "_suspicious_successive_range_count",
        "_character_count",
        "_last_printable_seen",
        "_last_printable_range",
    )

    def __init__(self) -> None:
        self._suspicious_successive_range_count: int = 0
        self._character_count: int = 0
        self._last_printable_seen: str | None = None
        self._last_printable_range: str | None = None

    def eligible(self, character: str) -> bool:
        return character.isprintable()

    def feed(self, character: str) -> None:
        self._character_count += 1

        if (
            character.isspace()
            or is_punctuation(character)
            or character in COMMON_SAFE_ASCII_CHARACTERS
        ):
            self._last_printable_seen = None
            self._last_printable_range = None
            return

        if self._last_printable_seen is None:
            self._last_printable_seen = character
            self._last_printable_range = unicode_range(character)
            return

        unicode_range_a: str | None = self._last_printable_range
        unicode_range_b: str | None = unicode_range(character)

        if is_suspiciously_successive_range(unicode_range_a, unicode_range_b):
            self._suspicious_successive_range_count += 1

        self._last_printable_seen = character
        self._last_printable_range = unicode_range_b

    def reset(self) -> None:  # Abstract
        self._character_count = 0
        self._suspicious_successive_range_count = 0
        self._last_printable_seen = None
        self._last_printable_range = None

    @property
    def ratio(self) -> float:
        if self._character_count <= 13:
            return 0.0

        ratio_of_suspicious_range_usage: float = (
            self._suspicious_successive_range_count * 2
        ) / self._character_count

        return ratio_of_suspicious_range_usage


@final
class SuperWeirdWordPlugin(MessDetectorPlugin):
    __slots__ = (
        "_word_count",
        "_bad_word_count",
        "_foreign_long_count",
        "_is_current_word_bad",
        "_foreign_long_watch",
        "_character_count",
        "_bad_character_count",
        "_buffer_length",
        "_buffer_last_char",
        "_buffer_last_char_accentuated",
        "_buffer_accent_count",
        "_buffer_glyph_count",
        "_buffer_upper_count",
    )

    def __init__(self) -> None:
        self._word_count: int = 0
        self._bad_word_count: int = 0
        self._foreign_long_count: int = 0

        self._is_current_word_bad: bool = False
        self._foreign_long_watch: bool = False

        self._character_count: int = 0
        self._bad_character_count: int = 0

        self._buffer_length: int = 0
        self._buffer_last_char: str | None = None
        self._buffer_last_char_accentuated: bool = False
        self._buffer_accent_count: int = 0
        self._buffer_glyph_count: int = 0
        self._buffer_upper_count: int = 0

    def eligible(self, character: str) -> bool:
        return True

    def feed(self, character: str) -> None:
        if character.isalpha():
            self._buffer_length += 1
            self._buffer_last_char = character

            if character.isupper():
                self._buffer_upper_count += 1

            flags: int = _character_flags(character)
            char_accentuated: bool = bool(flags & _ACCENTUATED)
            self._buffer_last_char_accentuated = char_accentuated

            if char_accentuated:
                self._buffer_accent_count += 1
            if (
                not self._foreign_long_watch
                and (not (flags & _LATIN) or char_accentuated)
                and not (flags & _GLYPH_MASK)
            ):
                self._foreign_long_watch = True
            if flags & _GLYPH_MASK:
                self._buffer_glyph_count += 1
            return
        if not self._buffer_length:
            return
        if (
            character.isspace() or is_punctuation(character) or is_separator(character)
        ) and self._buffer_length:
            self._word_count += 1
            buffer_length: int = self._buffer_length

            self._character_count += buffer_length

            if buffer_length >= 4:
                if self._buffer_accent_count / buffer_length >= 0.5:
                    self._is_current_word_bad = True
                # Word/Buffer ending with an upper case accentuated letter are so rare,
                # that we will consider them all as suspicious. Same weight as foreign_long suspicious.
                elif (
                    self._buffer_last_char_accentuated
                    and self._buffer_last_char.isupper()  # type: ignore[union-attr]
                    and self._buffer_upper_count != buffer_length
                ):
                    self._foreign_long_count += 1
                    self._is_current_word_bad = True
                elif self._buffer_glyph_count == 1:
                    self._is_current_word_bad = True
                    self._foreign_long_count += 1
            if buffer_length >= 24 and self._foreign_long_watch:
                probable_camel_cased: bool = (
                    self._buffer_upper_count > 0
                    and self._buffer_upper_count / buffer_length <= 0.3
                )

                if not probable_camel_cased:
                    self._foreign_long_count += 1
                    self._is_current_word_bad = True

            if self._is_current_word_bad:
                self._bad_word_count += 1
                self._bad_character_count += buffer_length
                self._is_current_word_bad = False

            self._foreign_long_watch = False
            self._buffer_length = 0
            self._buffer_last_char = None
            self._buffer_last_char_accentuated = False
            self._buffer_accent_count = 0
            self._buffer_glyph_count = 0
            self._buffer_upper_count = 0
        elif (
            character not in {"<", ">", "-", "=", "~", "|", "_"}
            and not character.isdigit()
            and is_symbol(character)
        ):
            self._is_current_word_bad = True
            self._buffer_length += 1
            self._buffer_last_char = character
            self._buffer_last_char_accentuated = False

    def reset(self) -> None:  # Abstract
        self._buffer_length = 0
        self._buffer_last_char = None
        self._buffer_last_char_accentuated = False
        self._is_current_word_bad = False
        self._foreign_long_watch = False
        self._bad_word_count = 0
        self._word_count = 0
        self._character_count = 0
        self._bad_character_count = 0
        self._foreign_long_count = 0
        self._buffer_accent_count = 0
        self._buffer_glyph_count = 0
        self._buffer_upper_count = 0

    @property
    def ratio(self) -> float:
        if self._word_count <= 10 and self._foreign_long_count == 0:
            return 0.0

        return self._bad_character_count / self._character_count


@final
class CjkUncommonPlugin(MessDetectorPlugin):
    """
    Detect messy CJK text that probably means nothing.
    """

    __slots__ = ("_character_count", "_uncommon_count")

    def __init__(self) -> None:
        self._character_count: int = 0
        self._uncommon_count: int = 0

    def eligible(self, character: str) -> bool:
        return is_cjk(character)

    def feed(self, character: str) -> None:
        self._character_count += 1

        if is_cjk_uncommon(character):
            self._uncommon_count += 1
            return

    def reset(self) -> None:  # Abstract
        self._character_count = 0
        self._uncommon_count = 0

    @property
    def ratio(self) -> float:
        if self._character_count < 8:
            return 0.0

        uncommon_form_usage: float = self._uncommon_count / self._character_count

        # we can be pretty sure it's garbage when uncommon characters are widely
        # used. otherwise it could just be traditional chinese for example.
        return uncommon_form_usage / 10 if uncommon_form_usage > 0.5 else 0.0


@final
class ArchaicUpperLowerPlugin(MessDetectorPlugin):
    __slots__ = (
        "_buf",
        "_character_count_since_last_sep",
        "_successive_upper_lower_count",
        "_successive_upper_lower_count_final",
        "_character_count",
        "_last_alpha_seen",
        "_current_ascii_only",
    )

    def __init__(self) -> None:
        self._buf: bool = False

        self._character_count_since_last_sep: int = 0

        self._successive_upper_lower_count: int = 0
        self._successive_upper_lower_count_final: int = 0

        self._character_count: int = 0

        self._last_alpha_seen: str | None = None
        self._current_ascii_only: bool = True

    def eligible(self, character: str) -> bool:
        return True

    def feed(self, character: str) -> None:
        is_concerned: bool = character.isalpha() and is_case_variable(character)
        chunk_sep: bool = not is_concerned

        if chunk_sep and self._character_count_since_last_sep > 0:
            if (
                self._character_count_since_last_sep <= 64
                and not character.isdigit()
                and not self._current_ascii_only
            ):
                self._successive_upper_lower_count_final += (
                    self._successive_upper_lower_count
                )

            self._successive_upper_lower_count = 0
            self._character_count_since_last_sep = 0
            self._last_alpha_seen = None
            self._buf = False
            self._character_count += 1
            self._current_ascii_only = True

            return

        if self._current_ascii_only and not character.isascii():
            self._current_ascii_only = False

        if self._last_alpha_seen is not None:
            if (character.isupper() and self._last_alpha_seen.islower()) or (
                character.islower() and self._last_alpha_seen.isupper()
            ):
                if self._buf:
                    self._successive_upper_lower_count += 2
                    self._buf = False
                else:
                    self._buf = True
            else:
                self._buf = False

        self._character_count += 1
        self._character_count_since_last_sep += 1
        self._last_alpha_seen = character

    def reset(self) -> None:  # Abstract
        self._character_count = 0
        self._character_count_since_last_sep = 0
        self._successive_upper_lower_count = 0
        self._successive_upper_lower_count_final = 0
        self._last_alpha_seen = None
        self._buf = False
        self._current_ascii_only = True

    @property
    def ratio(self) -> float:
        if self._character_count == 0:
            return 0.0

        return self._successive_upper_lower_count_final / self._character_count


@final
class ArabicIsolatedFormPlugin(MessDetectorPlugin):
    __slots__ = ("_character_count", "_isolated_form_count")

    def __init__(self) -> None:
        self._character_count: int = 0
        self._isolated_form_count: int = 0

    def reset(self) -> None:  # Abstract
        self._character_count = 0
        self._isolated_form_count = 0

    def eligible(self, character: str) -> bool:
        return is_arabic(character)

    def feed(self, character: str) -> None:
        self._character_count += 1

        if is_arabic_isolated_form(character):
            self._isolated_form_count += 1

    @property
    def ratio(self) -> float:
        if self._character_count < 8:
            return 0.0

        isolated_form_usage: float = self._isolated_form_count / self._character_count

        return isolated_form_usage


@lru_cache(maxsize=1024)
def is_suspiciously_successive_range(
    unicode_range_a: str | None, unicode_range_b: str | None
) -> bool:
    """
    Determine if two Unicode range seen next to each other can be considered as suspicious.
    """
    if unicode_range_a is None or unicode_range_b is None:
        return True

    if unicode_range_a == unicode_range_b:
        return False

    if "Latin" in unicode_range_a and "Latin" in unicode_range_b:
        return False

    if "Emoticons" in unicode_range_a or "Emoticons" in unicode_range_b:
        return False

    # Latin characters can be accompanied with a combining diacritical mark
    # eg. Vietnamese.
    if ("Latin" in unicode_range_a or "Latin" in unicode_range_b) and (
        "Combining" in unicode_range_a or "Combining" in unicode_range_b
    ):
        return False

    keywords_range_a, keywords_range_b = (
        unicode_range_a.split(" "),
        unicode_range_b.split(" "),
    )

    for el in keywords_range_a:
        if el in UNICODE_SECONDARY_RANGE_KEYWORD:
            continue
        if el in keywords_range_b:
            return False

    # Japanese Exception
    range_a_jp_chars, range_b_jp_chars = (
        unicode_range_a
        in (
            "Hiragana",
            "Katakana",
        ),
        unicode_range_b in ("Hiragana", "Katakana"),
    )
    if (range_a_jp_chars or range_b_jp_chars) and (
        "CJK" in unicode_range_a or "CJK" in unicode_range_b
    ):
        return False
    if range_a_jp_chars and range_b_jp_chars:
        return False

    if "Hangul" in unicode_range_a or "Hangul" in unicode_range_b:
        if "CJK" in unicode_range_a or "CJK" in unicode_range_b:
            return False
        if unicode_range_a == "Basic Latin" or unicode_range_b == "Basic Latin":
            return False

    # Chinese/Japanese use dedicated range for punctuation and/or separators.
    if ("CJK" in unicode_range_a or "CJK" in unicode_range_b) or (
        unicode_range_a in ["Katakana", "Hiragana"]
        and unicode_range_b in ["Katakana", "Hiragana"]
    ):
        if "Punctuation" in unicode_range_a or "Punctuation" in unicode_range_b:
            return False
        if "Forms" in unicode_range_a or "Forms" in unicode_range_b:
            return False
        if unicode_range_a == "Basic Latin" or unicode_range_b == "Basic Latin":
            return False

    return True


# import time messdetector plugins detection(...)
_DETECTOR_CLASSES: tuple[type[MessDetectorPlugin], ...] = tuple(
    md_class for md_class in MessDetectorPlugin.__subclasses__()
)


@lru_cache(maxsize=2048)
def mess_ratio(
    decoded_sequence: str, maximum_threshold: float = 0.2, debug: bool = False
) -> float:
    """
    Compute a mess ratio given a decoded bytes sequence. The maximum threshold does stop the computation earlier.
    """

    detectors: list[MessDetectorPlugin] = [md_class() for md_class in _DETECTOR_CLASSES]

    mean_mess_ratio: float
    seq_len: int = len(decoded_sequence)

    if seq_len < 511:
        step: int = 32
    elif seq_len < 1024:
        step = 64
    else:
        step = 128

    for block_start in range(0, seq_len, step):
        for character in decoded_sequence[block_start : block_start + step]:
            for detector in detectors:
                if detector.eligible(character):
                    detector.feed(character)

        mean_mess_ratio = sum(dt.ratio for dt in detectors)

        if mean_mess_ratio >= maximum_threshold:
            break
    else:
        # Flush last word buffer in SuperWeirdWordPlugin via trailing newline.
        for detector in detectors:
            if detector.eligible("\n"):
                detector.feed("\n")
        mean_mess_ratio = sum(dt.ratio for dt in detectors)

    if debug:
        logger = getLogger("charset_normalizer")

        logger.log(
            TRACE,
            "Mess-detector extended-analysis start. "
            f"intermediary_mean_mess_ratio_calc={step} mean_mess_ratio={mean_mess_ratio} "
            f"maximum_threshold={maximum_threshold}",
        )

        if seq_len > 16:
            logger.log(TRACE, f"Starting with: {decoded_sequence[:16]}")
            logger.log(TRACE, f"Ending with: {decoded_sequence[-16::]}")

        for dt in detectors:
            logger.log(TRACE, f"{dt.__class__}: {dt.ratio}")

    return round(mean_mess_ratio, 3)


path: path/to/venv/lib/python3.14/site-packages/charset_normalizer/models.py
content: from __future__ import annotations

from encodings.aliases import aliases
from json import dumps
from re import sub
from typing import Any, Iterator, List, Tuple

from .constant import RE_POSSIBLE_ENCODING_INDICATION, TOO_BIG_SEQUENCE
from .utils import iana_name, is_multi_byte_encoding, unicode_range


class CharsetMatch:
    def __init__(
        self,
        payload: bytes,
        guessed_encoding: str,
        mean_mess_ratio: float,
        has_sig_or_bom: bool,
        languages: CoherenceMatches,
        decoded_payload: str | None = None,
        preemptive_declaration: str | None = None,
    ):
        self._payload: bytes = payload

        self._encoding: str = guessed_encoding
        self._mean_mess_ratio: float = mean_mess_ratio
        self._languages: CoherenceMatches = languages
        self._has_sig_or_bom: bool = has_sig_or_bom
        self._unicode_ranges: list[str] | None = None

        self._leaves: list[CharsetMatch] = []
        self._mean_coherence_ratio: float = 0.0

        self._output_payload: bytes | None = None
        self._output_encoding: str | None = None

        self._string: str | None = decoded_payload

        self._preemptive_declaration: str | None = preemptive_declaration

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, CharsetMatch):
            if isinstance(other, str):
                return iana_name(other) == self.encoding
            return False
        return self.encoding == other.encoding and self.fingerprint == other.fingerprint

    def __lt__(self, other: object) -> bool:
        """
        Implemented to make sorted available upon CharsetMatches items.
        """
        if not isinstance(other, CharsetMatch):
            raise ValueError

        chaos_difference: float = abs(self.chaos - other.chaos)
        coherence_difference: float = abs(self.coherence - other.coherence)

        # Below 1% difference --> Use Coherence
        if chaos_difference < 0.01 and coherence_difference > 0.02:
            return self.coherence > other.coherence
        elif chaos_difference < 0.01 and coherence_difference <= 0.02:
            # When having a difficult decision, use the result that decoded as many multi-byte as possible.
            # preserve RAM usage!
            if len(self._payload) >= TOO_BIG_SEQUENCE:
                return self.chaos < other.chaos
            return self.multi_byte_usage > other.multi_byte_usage

        return self.chaos < other.chaos

    @property
    def multi_byte_usage(self) -> float:
        return 1.0 - (len(str(self)) / len(self.raw))

    def __str__(self) -> str:
        # Lazy Str Loading
        if self._string is None:
            self._string = str(self._payload, self._encoding, "strict")
        return self._string

    def __repr__(self) -> str:
        return f"<CharsetMatch '{self.encoding}' fp({self.fingerprint})>"

    def add_submatch(self, other: CharsetMatch) -> None:
        if not isinstance(other, CharsetMatch) or other == self:
            raise ValueError(
                "Unable to add instance <{}> as a submatch of a CharsetMatch".format(
                    other.__class__
                )
            )

        other._string = None  # Unload RAM usage; dirty trick.
        self._leaves.append(other)

    @property
    def encoding(self) -> str:
        return self._encoding

    @property
    def encoding_aliases(self) -> list[str]:
        """
        Encoding name are known by many name, using this could help when searching for IBM855 when it's listed as CP855.
        """
        also_known_as: list[str] = []
        for u, p in aliases.items():
            if self.encoding == u:
                also_known_as.append(p)
            elif self.encoding == p:
                also_known_as.append(u)
        return also_known_as

    @property
    def bom(self) -> bool:
        return self._has_sig_or_bom

    @property
    def byte_order_mark(self) -> bool:
        return self._has_sig_or_bom

    @property
    def languages(self) -> list[str]:
        """
        Return the complete list of possible languages found in decoded sequence.
        Usually not really useful. Returned list may be empty even if 'language' property return something != 'Unknown'.
        """
        return [e[0] for e in self._languages]

    @property
    def language(self) -> str:
        """
        Most probable language found in decoded sequence. If none were detected or inferred, the property will return
        "Unknown".
        """
        if not self._languages:
            # Trying to infer the language based on the given encoding
            # Its either English or we should not pronounce ourselves in certain cases.
            if "ascii" in self.could_be_from_charset:
                return "English"

            # doing it there to avoid circular import
            from charset_normalizer.cd import encoding_languages, mb_encoding_languages

            languages = (
                mb_encoding_languages(self.encoding)
                if is_multi_byte_encoding(self.encoding)
                else encoding_languages(self.encoding)
            )

            if len(languages) == 0 or "Latin Based" in languages:
                return "Unknown"

            return languages[0]

        return self._languages[0][0]

    @property
    def chaos(self) -> float:
        return self._mean_mess_ratio

    @property
    def coherence(self) -> float:
        if not self._languages:
            return 0.0
        return self._languages[0][1]

    @property
    def percent_chaos(self) -> float:
        return round(self.chaos * 100, ndigits=3)

    @property
    def percent_coherence(self) -> float:
        return round(self.coherence * 100, ndigits=3)

    @property
    def raw(self) -> bytes:
        """
        Original untouched bytes.
        """
        return self._payload

    @property
    def submatch(self) -> list[CharsetMatch]:
        return self._leaves

    @property
    def has_submatch(self) -> bool:
        return len(self._leaves) > 0

    @property
    def alphabets(self) -> list[str]:
        if self._unicode_ranges is not None:
            return self._unicode_ranges
        # list detected ranges
        detected_ranges: list[str | None] = [unicode_range(char) for char in str(self)]
        # filter and sort
        self._unicode_ranges = sorted(list({r for r in detected_ranges if r}))
        return self._unicode_ranges

    @property
    def could_be_from_charset(self) -> list[str]:
        """
        The complete list of encoding that output the exact SAME str result and therefore could be the originating
        encoding.
        This list does include the encoding available in property 'encoding'.
        """
        return [self._encoding] + [m.encoding for m in self._leaves]

    def output(self, encoding: str = "utf_8") -> bytes:
        """
        Method to get re-encoded bytes payload using given target encoding. Default to UTF-8.
        Any errors will be simply ignored by the encoder NOT replaced.
        """
        if self._output_encoding is None or self._output_encoding != encoding:
            self._output_encoding = encoding
            decoded_string = str(self)
            if (
                self._preemptive_declaration is not None
                and self._preemptive_declaration.lower()
                not in ["utf-8", "utf8", "utf_8"]
            ):
                patched_header = sub(
                    RE_POSSIBLE_ENCODING_INDICATION,
                    lambda m: m.string[m.span()[0] : m.span()[1]].replace(
                        m.groups()[0],
                        iana_name(self._output_encoding).replace("_", "-"),  # type: ignore[arg-type]
                    ),
                    decoded_string[:8192],
                    count=1,
                )

                decoded_string = patched_header + decoded_string[8192:]

            self._output_payload = decoded_string.encode(encoding, "replace")

        return self._output_payload  # type: ignore

    @property
    def fingerprint(self) -> int:
        """
        Retrieve a hash fingerprint of the decoded payload, used for deduplication.
        """
        return hash(str(self))


class CharsetMatches:
    """
    Container with every CharsetMatch items ordered by default from most probable to the less one.
    Act like a list(iterable) but does not implements all related methods.
    """

    def __init__(self, results: list[CharsetMatch] | None = None):
        self._results: list[CharsetMatch] = sorted(results) if results else []

    def __iter__(self) -> Iterator[CharsetMatch]:
        yield from self._results

    def __getitem__(self, item: int | str) -> CharsetMatch:
        """
        Retrieve a single item either by its position or encoding name (alias may be used here).
        Raise KeyError upon invalid index or encoding not present in results.
        """
        if isinstance(item, int):
            return self._results[item]
        if isinstance(item, str):
            item = iana_name(item, False)
            for result in self._results:
                if item in result.could_be_from_charset:
                    return result
        raise KeyError

    def __len__(self) -> int:
        return len(self._results)

    def __bool__(self) -> bool:
        return len(self._results) > 0

    def append(self, item: CharsetMatch) -> None:
        """
        Insert a single match. Will be inserted accordingly to preserve sort.
        Can be inserted as a submatch.
        """
        if not isinstance(item, CharsetMatch):
            raise ValueError(
                "Cannot append instance '{}' to CharsetMatches".format(
                    str(item.__class__)
                )
            )
        # We should disable the submatch factoring when the input file is too heavy (conserve RAM usage)
        if len(item.raw) < TOO_BIG_SEQUENCE:
            for match in self._results:
                if match.fingerprint == item.fingerprint and match.chaos == item.chaos:
                    match.add_submatch(item)
                    return
        self._results.append(item)
        self._results = sorted(self._results)

    def best(self) -> CharsetMatch | None:
        """
        Simply return the first match. Strict equivalent to matches[0].
        """
        if not self._results:
            return None
        return self._results[0]

    def first(self) -> CharsetMatch | None:
        """
        Redundant method, call the method best(). Kept for BC reasons.
        """
        return self.best()


CoherenceMatch = Tuple[str, float]
CoherenceMatches = List[CoherenceMatch]


class CliDetectionResult:
    def __init__(
        self,
        path: str,
        encoding: str | None,
        encoding_aliases: list[str],
        alternative_encodings: list[str],
        language: str,
        alphabets: list[str],
        has_sig_or_bom: bool,
        chaos: float,
        coherence: float,
        unicode_path: str | None,
        is_preferred: bool,
    ):
        self.path: str = path
        self.unicode_path: str | None = unicode_path
        self.encoding: str | None = encoding
        self.encoding_aliases: list[str] = encoding_aliases
        self.alternative_encodings: list[str] = alternative_encodings
        self.language: str = language
        self.alphabets: list[str] = alphabets
        self.has_sig_or_bom: bool = has_sig_or_bom
        self.chaos: float = chaos
        self.coherence: float = coherence
        self.is_preferred: bool = is_preferred

    @property
    def __dict__(self) -> dict[str, Any]:  # type: ignore
        return {
            "path": self.path,
            "encoding": self.encoding,
            "encoding_aliases": self.encoding_aliases,
            "alternative_encodings": self.alternative_encodings,
            "language": self.language,
            "alphabets": self.alphabets,
            "has_sig_or_bom": self.has_sig_or_bom,
            "chaos": self.chaos,
            "coherence": self.coherence,
            "unicode_path": self.unicode_path,
            "is_preferred": self.is_preferred,
        }

    def to_json(self) -> str:
        return dumps(self.__dict__, ensure_ascii=True, indent=4)


path: path/to/venv/lib/python3.14/site-packages/charset_normalizer/version.py
content: """
Expose version
"""

from __future__ import annotations

__version__ = "3.4.5"
VERSION = __version__.split(".")


path: path/to/venv/lib/python3.14/site-packages/charset_normalizer-3.4.5.dist-info/entry_points.txt
content: [console_scripts]
normalizer = charset_normalizer.cli:cli_detect


path: path/to/venv/lib/python3.14/site-packages/charset_normalizer-3.4.5.dist-info/top_level.txt
content: 81d243bd2c585b0f4821__mypyc
charset_normalizer


path: path/to/venv/lib/python3.14/site-packages/docutils/__main__.py
content: #!/usr/bin/env python3
# :Copyright: © 2020, 2022 Günter Milde.
# :License: Released under the terms of the `2-Clause BSD license`_, in short:
#
#    Copying and distribution of this file, with or without modification,
#    are permitted in any medium without royalty provided the copyright
#    notice and this notice are preserved.
#    This file is offered as-is, without any warranty.
#
# .. _2-Clause BSD license: https://opensource.org/licenses/BSD-2-Clause
#
# Revision: $Revision: 10136 $
# Date: $Date: 2025-05-20 17:48:27 +0200 (Di, 20. Mai 2025) $

"""Generic command line interface for the `docutils` package.

See also
https://docs.python.org/3/library/__main__.html#main-py-in-python-packages
"""

from __future__ import annotations

__docformat__ = 'reStructuredText'

import argparse
import locale
import sys

import docutils
from docutils.core import Publisher, publish_cmdline, default_description


class CliSettingsSpec(docutils.SettingsSpec):
    """Runtime settings & command-line options for the generic CLI.

    Configurable reader, parser, and writer components.

    The "--writer" default will change to 'html' in Docutils 2.0
    when 'html' becomes an alias for the current value 'html5'.
    """

    settings_spec = (
        'Docutils Application Options',
        'Reader, writer, and parser settings influence the available options. '
        '  Example: use `--help --writer=latex` to see LaTeX writer options. ',
        # options: ('help text', [<option strings>], {<keyword arguments>})
        (('Reader name (currently: "%default").',
          ['--reader'], {'default': 'standalone', 'metavar': '<reader>'}),
         ('Parser name (currently: "%default").',
          ['--parser'], {'default': 'rst', 'metavar': '<parser>'}),
         ('Writer name (currently: "%default").',
          ['--writer'], {'default': 'html5', 'metavar': '<writer>'}),
         )
    )
    config_section = 'docutils application'
    config_section_dependencies = ('docutils-cli application',  # back-compat
                                   'applications')


def main() -> None:
    """Generic command line interface for the Docutils Publisher.
    """
    try:
        locale.setlocale(locale.LC_ALL, '')
    except locale.Error as e:
        sys.stderr.write(f'WARNING: Cannot set the default locale: {e}.\n')

    description = ('Convert documents into useful formats.  '
                   + default_description)

    # Update component selection from config file(s)
    components = Publisher().get_settings(settings_spec=CliSettingsSpec)

    # Update component selection from command-line
    argparser = argparse.ArgumentParser(add_help=False, allow_abbrev=False)
    argparser.add_argument('--reader', default=components.reader)
    argparser.add_argument('--parser', default=components.parser)
    argparser.add_argument('--writer', default=components.writer)
    # other options are parsed in a second pass via `publish_cmdline()`
    (args, remainder) = argparser.parse_known_args()
    # Ensure the current component selections are shown in help:
    CliSettingsSpec.settings_default_overrides = args.__dict__

    try:
        publish_cmdline(reader=args.reader,
                        parser=args.parser,
                        writer=args.writer,
                        settings_spec=CliSettingsSpec,
                        description=description,
                        argv=remainder)
    except ImportError as error:
        print('%s.' % error, file=sys.stderr)
        if '--traceback' in remainder:
            raise
        else:
            print('Use "--traceback" to show details.')


if __name__ == '__main__':
    if sys.argv[0].endswith('__main__.py'):
        # fix "usage" message
        sys.argv[0] = '%s -m docutils' % sys.executable
    main()


path: path/to/venv/lib/python3.14/site-packages/docutils/parsers/rst/include/isogrk4.txt
content: .. This data file has been placed in the public domain.
.. Derived from the Unicode character mappings available from
   <http://www.w3.org/2003/entities/xml/>.
   Processed by unicode2rstsubs.py, part of Docutils:
   <https://docutils.sourceforge.io>.

.. |b.Gammad| unicode:: U+003DC .. GREEK LETTER DIGAMMA
.. |b.gammad| unicode:: U+003DD .. GREEK SMALL LETTER DIGAMMA


path: path/to/venv/lib/python3.14/site-packages/id-1.6.1.dist-info/entry_points.txt
content: [pipx.run]
id=id.__main__:main



path: path/to/venv/lib/python3.14/site-packages/idna/package_data.py
content: __version__ = "3.11"


path: path/to/venv/lib/python3.14/site-packages/jaraco.classes-3.4.0.dist-info/top_level.txt
content: jaraco


path: path/to/venv/lib/python3.14/site-packages/jaraco_context-6.1.1.dist-info/top_level.txt
content: jaraco


path: path/to/venv/lib/python3.14/site-packages/jaraco_functools-4.4.0.dist-info/top_level.txt
content: jaraco


path: path/to/venv/lib/python3.14/site-packages/keyring-25.7.0.dist-info/top_level.txt
content: keyring


path: path/to/venv/lib/python3.14/site-packages/pip/_vendor/idna/package_data.py
content: __version__ = "3.11"


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)