Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 3 additions & 2 deletions plugins/macdoc/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
}
Expand Down
6 changes: 6 additions & 0 deletions plugins/macdoc/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions plugins/macdoc/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-start.sh"
}
]
}
]
}
}
79 changes: 79 additions & 0 deletions plugins/macdoc/hooks/session-start.sh
Original file line number Diff line number Diff line change
@@ -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 </dev/null >"$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
2 changes: 1 addition & 1 deletion plugins/macdoc/skills/macdoc/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

## 子命令總覽
Expand Down
97 changes: 97 additions & 0 deletions scripts/release-cli.sh
Original file line number Diff line number Diff line change
@@ -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 <version> # 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 <version> (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)"
Loading