diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 9c780fa..847ada4 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -20,13 +20,14 @@ }, { "name": "macdoc", - "version": "1.1.0", - "description": "macOS 原生文件處理 CLI — 格式轉換、VLM OCR(含 host profile 設定)、SRT 處理", + "version": "1.2.0", + "description": "macOS 原生文件處理 CLI — 格式轉換、VLM OCR(含 host profile 設定)、SRT 處理。v1.2.0: session-start hook 自動安裝 signed CLI binary(arm64)。", "author": { "name": "Che Cheng" }, "source": "./plugins/macdoc", - "category": "productivity" + "category": "productivity", + "binary_version": "0.5.0" }, { "name": "che-pdf-mcp", diff --git a/plugins/macdoc/.claude-plugin/plugin.json b/plugins/macdoc/.claude-plugin/plugin.json index 2a205cd..8873228 100644 --- a/plugins/macdoc/.claude-plugin/plugin.json +++ b/plugins/macdoc/.claude-plugin/plugin.json @@ -1,7 +1,8 @@ { "name": "macdoc", - "description": "macOS 原生文件處理 CLI — 格式轉換、VLM OCR(含 host profile 設定)、SRT 處理", - "version": "1.1.0", + "description": "macOS 原生文件處理 CLI — 格式轉換、VLM OCR(含 host profile 設定)、SRT 處理。v1.2.0: session-start hook 自動安裝 signed CLI binary(arm64)。", + "version": "1.2.0", + "binary_version": "0.5.0", "author": { "name": "Che Cheng" } diff --git a/plugins/macdoc/CHANGELOG.md b/plugins/macdoc/CHANGELOG.md index e5b1099..a2bf0fb 100644 --- a/plugins/macdoc/CHANGELOG.md +++ b/plugins/macdoc/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 > `plugin.json` description field. Section categorization is best-effort — > review and refine `Added` / `Changed` / `Fixed` etc. as needed. +## [1.2.0] - 2026-07-02 + +### Added + +- **CLI binary 自動安裝(PsychQuant/macdoc#114)**:`hooks/session-start.sh` 於 session 啟動比對 `~/bin/macdoc --version` 與 `binary_version`(=0.5.0,首次 CLI release),缺/不符即下載並以強制 sha256 + Developer ID requirement 驗證後安裝(與 MCP wrappers / release gate 同一把尺)。Session fail-soft(任何失敗警告即止、絕不擋 session)、artifact fail-closed(未驗過不裝)。arm64-only(CLI 依賴 MLX)。 + ## [Unreleased] ## [1.1.0] - (date unknown — please fill in) diff --git a/plugins/macdoc/hooks/hooks.json b/plugins/macdoc/hooks/hooks.json new file mode 100644 index 0000000..7de73c0 --- /dev/null +++ b/plugins/macdoc/hooks/hooks.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-start.sh" + } + ] + } + ] + } +} diff --git a/plugins/macdoc/hooks/session-start.sh b/plugins/macdoc/hooks/session-start.sh new file mode 100755 index 0000000..3fa8d32 --- /dev/null +++ b/plugins/macdoc/hooks/session-start.sh @@ -0,0 +1,79 @@ +#!/bin/bash +# macdoc plugin SessionStart hook — ensure ~/bin/macdoc matches the plugin's +# pinned binary_version (PsychQuant/macdoc#114). +# +# Contract: FAIL-SOFT for the session (any problem → one stderr line, exit 0 — +# never break session start), FAIL-CLOSED for the artifact (mandatory sha256 + +# Developer ID requirement — the same ruler as the MCP wrappers and the +# release gate — before anything is installed to ~/bin). +# +# Install target is the shared ~/bin ON PURPOSE (unlike the MCP wrappers' +# plugin-scoped .bin-cache, #117): the CLI is a user-facing PATH tool and +# ~/bin IS its destination; the cross-marketplace binary collision problem +# does not apply to a CLI the user invokes by name. + +set -u + +REPO="PsychQuant/macdoc" +BINARY_NAME="macdoc" +[ -n "${HOME:-}" ] || exit 0 # no HOME (exotic env) — nothing sane to do, never break session +INSTALL_DIR="${MACDOC_INSTALL_DIR:-$HOME/bin}" # override for tests +BINARY="$INSTALL_DIR/$BINARY_NAME" +REQUIREMENT='=anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] exists and certificate leaf[field.1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = "6W377FS7BS"' + +note() { echo "macdoc plugin: $1" >&2; } +soft_exit() { note "$1"; exit 0; } # fail-soft: never break session start + +[ "$(uname -m)" = "arm64" ] || exit 0 # arm64-only release; Intel builds from source (silent — not an error) + +PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PLUGIN_JSON="$PLUGIN_ROOT/.claude-plugin/plugin.json" +[ -f "$PLUGIN_JSON" ] || exit 0 + +WANT=$(grep -oE '"binary_version"[[:space:]]*:[[:space:]]*"[^"]+"' "$PLUGIN_JSON" 2>/dev/null \ + | head -1 | sed -E 's/.*"([^"]+)"$/\1/' || true) +[ -n "$WANT" ] || exit 0 # no pinned CLI version — nothing to manage + +# --version with a 5s alarm (a hung/planted binary must not stall every +# session start — fail-soft covers errors, not hangs; codex V114 HIGH-1). +# Probe writes to a FILE, not a pipe: a killed probe may leave grandchildren +# holding an inherited pipe fd, and command substitution would then wait on +# the pipe far past the alarm (empirically reproduced with a sleep-300 fake). +# Normalize to the semver token so banner-style output doesn't force a +# re-download loop (codex V114 M-2). +HAVE="" +PROBE=$(mktemp "${TMPDIR:-/tmp}/.macdoc.probe.XXXXXX" 2>/dev/null) || PROBE="" +if [ -n "$PROBE" ]; then + { perl -e 'alarm 5; exec @ARGV' -- "$BINARY" --version "$PROBE" 2>/dev/null; } 2>/dev/null || true + HAVE=$(head -1 "$PROBE" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true) + rm -f "$PROBE" +fi +[ "$HAVE" = "$WANT" ] && exit 0 # fast path: version matches, zero network + +# Loop-guard sidecar: if a previous session already installed WANT but the +# binary self-reports an unparsable/odd version, do not re-download forever. +GUARD="$INSTALL_DIR/.${BINARY_NAME}.installed_version" +[ -x "$BINARY" ] && [ "$(cat "$GUARD" 2>/dev/null)" = "$WANT" ] && exit 0 + +mkdir -p "$INSTALL_DIR" 2>/dev/null || soft_exit "cannot create $INSTALL_DIR — skipping auto-install" +TMP=$(mktemp "$INSTALL_DIR/.${BINARY_NAME}.download.XXXXXX" 2>/dev/null) || soft_exit "mktemp failed — skipping auto-install" +trap 'rm -f "$TMP"' EXIT + +URL="https://github.com/$REPO/releases/download/v$WANT/$BINARY_NAME" +curl -fsSL --proto '=https' --tlsv1.2 --max-time 300 "$URL" -o "$TMP" 2>/dev/null \ + || soft_exit "download failed for v$WANT (keeping existing ${HAVE:-none}); manual: https://github.com/$REPO/releases" + +EXPECTED=$(curl -fsSL --proto '=https' --tlsv1.2 --max-time 30 "$URL.sha256" 2>/dev/null | head -1 | awk '{print $1}') +[[ "$EXPECTED" =~ ^[0-9a-fA-F]{64}$ ]] \ + || soft_exit "missing/malformed .sha256 asset — refusing to install unverified binary" +[[ "$(shasum -a 256 "$TMP" | awk '{print $1}')" == "$EXPECTED" ]] \ + || soft_exit "sha256 mismatch — refusing to install" +codesign --verify --strict -R "$REQUIREMENT" "$TMP" 2>/dev/null \ + || soft_exit "code-signature verification failed (not Developer ID Team 6W377FS7BS) — refusing to install" + +chmod +x "$TMP" || soft_exit "chmod failed" +mv "$TMP" "$BINARY" || soft_exit "install mv failed" +trap - EXIT +echo "$WANT" > "$GUARD" 2>/dev/null || true +note "installed macdoc v$WANT to $INSTALL_DIR (sha256 + Developer ID verified)" +exit 0 diff --git a/plugins/macdoc/skills/macdoc/SKILL.md b/plugins/macdoc/skills/macdoc/SKILL.md index c07f6a8..1277e55 100644 --- a/plugins/macdoc/skills/macdoc/SKILL.md +++ b/plugins/macdoc/skills/macdoc/SKILL.md @@ -10,7 +10,7 @@ description: | # macdoc — macOS 原生文件處理 CLI -安裝位置:`~/bin/macdoc`(或 `/usr/local/bin/macdoc`) +安裝位置:`~/bin/macdoc` — **plugin 自動安裝**(session-start hook 下載 signed release 並驗證 sha256 + Developer ID;arm64。Intel 從原始碼建置) 原始碼:https://github.com/PsychQuant/macdoc ## 子命令總覽 diff --git a/scripts/release-cli.sh b/scripts/release-cli.sh new file mode 100755 index 0000000..f41a896 --- /dev/null +++ b/scripts/release-cli.sh @@ -0,0 +1,97 @@ +#!/bin/bash +# release-cli.sh — signed + notarized release pipeline for the macdoc CLI. +# +# arm64-only: the CLI depends on MLX (Apple Silicon exclusive), so a +# universal build is neither possible nor meaningful. Intel users build +# from source (README). +# +# Usage: scripts/release-cli.sh # e.g. scripts/release-cli.sh 0.6.0 +# +# Pipeline: arm64 release build → Developer ID codesign (hardened runtime + +# timestamp) → PRE-UPLOAD SIGNATURE GATE → notarize (must be Accepted) → +# arm64 arch check → sha256 → gh release (creates tag) with binary + .sha256. +# +# The gate exists because v3.20.0 of CheWordMCP shipped ad-hoc signed +# (che-word-mcp#165): the marketplace wrappers verify the SAME requirement +# on install/exec (PsychQuant/macdoc#112), so an unsigned asset means new +# installs hard-fail. Gate requirement string MUST stay in lockstep with +# the wrapper's verify_binary() (macdoc plugins/*/bin/*-wrapper.sh). +# +# SCOPE HONESTY (verify DA-1): this gate protects releases made THROUGH +# this script. A manual `gh release upload` bypasses it — the backstops +# for that path are process discipline and the wrappers' fail-closed +# install gate (which turns an unsigned asset into a loud install failure +# rather than a silent compromise). +# +# Refs PsychQuant/macdoc#119. + +set -euo pipefail + +BINARY_NAME="macdoc" +REPO="PsychQuant/macdoc" +DEVELOPER_ID="${DEVELOPER_ID:-F2523DCF6D02BE99B67C7D27F633119292DA4934}" +NOTARY_PROFILE="${NOTARY_PROFILE:-che-mcps-notary}" +REQUIREMENT='=anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] exists and certificate leaf[field.1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = "6W377FS7BS"' + +VERSION="${1:-}" +[[ -n "$VERSION" ]] || { echo "usage: scripts/release-cli.sh (e.g. 0.6.0, no leading v)" >&2; exit 2; } +[[ "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z][0-9A-Za-z.-]*)?$ ]] || { echo "error: version '$VERSION' is not semver (MAJOR.MINOR.PATCH[-prerelease], no leading zeros)" >&2; exit 2; } + +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +echo "→ [0/7] pre-flight: notary profile alive?" +xcrun notarytool history --keychain-profile "$NOTARY_PROFILE" >/dev/null 2>&1 \ + || { echo "error: notary profile '$NOTARY_PROFILE' unusable — run: xcrun notarytool store-credentials $NOTARY_PROFILE (interactive, user-only)" >&2; exit 3; } +[[ -z "$(git status --porcelain)" ]] \ + || { echo "error: working tree not clean (including untracked files — they could leak into the build) — commit, stash, or clean first" >&2; exit 3; } +if git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null 2>&1; then + echo "error: local tag v$VERSION already exists" >&2; exit 3 +fi +if [[ -n "$(git ls-remote --tags origin "refs/tags/v$VERSION" 2>/dev/null)" ]]; then + echo "error: remote tag v$VERSION already exists" >&2; exit 3 +fi +if gh release view "v$VERSION" --repo "$REPO" >/dev/null 2>&1; then + echo "error: release v$VERSION already exists on $REPO" >&2; exit 3 +fi + +echo "→ [1/7] release build (arm64 — MLX is Apple Silicon only)" +swift build -c release +BIN=".build/release/$BINARY_NAME" +[[ -f "$BIN" ]] || { echo "error: built binary not found at $BIN" >&2; exit 4; } + +echo "→ [2/7] codesign (Developer ID, hardened runtime, timestamp)" +codesign --force --options runtime --timestamp --sign "$DEVELOPER_ID" "$BIN" + +echo "→ [3/7] PRE-UPLOAD SIGNATURE GATE (requirement-based, matches wrapper)" +codesign --verify --strict -R "$REQUIREMENT" "$BIN" \ + || { echo "error: GATE FAILED — asset is not a Developer ID Application binary of Team 6W377FS7BS; refusing to release (this is the che-word-mcp#165 guard)" >&2; exit 5; } +ARCHS=" $(lipo -archs "$BIN" 2>/dev/null) " +[[ "$ARCHS" == *" arm64 "* ]] \ + || { echo "error: GATE FAILED — binary lacks arm64 (got:$ARCHS)" >&2; exit 5; } + +echo "→ [4/7] notarize (must be Accepted)" +WORKDIR=$(mktemp -d) +trap 'rm -rf "$WORKDIR"' EXIT +ditto -c -k --keepParent "$BIN" "$WORKDIR/$BINARY_NAME.zip" +NOTARY_OUT=$(xcrun notarytool submit "$WORKDIR/$BINARY_NAME.zip" --keychain-profile "$NOTARY_PROFILE" --wait 2>&1) +echo "$NOTARY_OUT" | grep -q "status: Accepted" \ + || { echo "error: notarization not Accepted:" >&2; echo "$NOTARY_OUT" | tail -5 >&2; exit 6; } + +echo "→ [5/7] sha256 asset" +cp "$BIN" "$WORKDIR/$BINARY_NAME" +shasum -a 256 "$WORKDIR/$BINARY_NAME" | awk '{print $1}' > "$WORKDIR/$BINARY_NAME.sha256" + +echo "→ [6/7] FINAL GATE — re-verify the exact upload artifact (TOCTOU guard)" +codesign --verify --strict -R "$REQUIREMENT" "$WORKDIR/$BINARY_NAME" \ + || { echo "error: FINAL GATE FAILED — upload artifact no longer passes the signature requirement (mutated after step 3?)" >&2; exit 5; } +[[ "$(shasum -a 256 "$WORKDIR/$BINARY_NAME" | awk '{print $1}')" == "$(cat "$WORKDIR/$BINARY_NAME.sha256")" ]] \ + || { echo "error: FINAL GATE FAILED — sha256 asset does not match upload artifact" >&2; exit 5; } + +echo "→ [7/7] gh release create (creates tag v$VERSION at HEAD — no pre-pushed tag, so a create failure leaves no dead-end state)" +gh release create "v$VERSION" --repo "$REPO" \ + --target "$(git rev-parse HEAD)" \ + --title "v$VERSION" \ + --notes "Developer ID signed + Apple notarized arm64 binary (CLI depends on MLX — Apple Silicon only; Intel builds from source). Released via scripts/release-cli.sh (pre-upload signature gate, PsychQuant/macdoc#119)." \ + "$WORKDIR/$BINARY_NAME" "$WORKDIR/$BINARY_NAME.sha256" + +echo "✓ released $BINARY_NAME v$VERSION (signed, notarized, gated, sha256 attached)"