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
252 changes: 252 additions & 0 deletions .github/workflows/cli-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
name: CLI and MCP Release Bundles

on:
workflow_run:
workflows: ["Release"]
types: [completed]
workflow_dispatch:
inputs:
tag:
description: "Release tag, for example v1.10.2"
required: true
type: string

permissions:
contents: write

concurrency:
group: cli-release-${{ github.event.workflow_run.head_sha || inputs.tag }}
cancel-in-progress: false

jobs:
bundle:
name: CLI + MCP ${{ matrix.platform }}
if: >-
github.event_name == 'workflow_dispatch' ||
github.event.workflow_run.conclusion == 'success'
strategy:
fail-fast: false
matrix:
include:
- platform: linux-x86_64
os: ubuntu-22.04
target: x86_64-unknown-linux-musl
- platform: macos-universal
os: macos-15
target: universal
- platform: windows-x86_64
os: windows-2022
target: x86_64-pc-windows-msvc
runs-on: ${{ matrix.os }}
env:
GH_TOKEN: ${{ github.token }}

steps:
- name: Checkout release commit
uses: actions/checkout@v7
with:
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || inputs.tag }}
fetch-depth: 0

- name: Resolve version
shell: bash
run: |
set -euo pipefail
PYTHON=python3
command -v python3 >/dev/null 2>&1 || PYTHON=python
version="$("$PYTHON" -c 'import json; print(json.load(open("apps/shield-gui/package.json", encoding="utf-8"))["version"])')"
tag="v${version}"
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
test "${{ inputs.tag }}" = "$tag"
fi
if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json isDraft --jq '.isDraft' > draft.txt; then
echo "release $tag not found" >&2
exit 1
fi
test "$(cat draft.txt)" = "false"
echo "TAG=$tag" >> "$GITHUB_ENV"
echo "VERSION=$version" >> "$GITHUB_ENV"
echo "PLATFORM=${{ matrix.platform }}" >> "$GITHUB_ENV"

- name: Install Rust targets
shell: bash
run: |
set -euo pipefail
case "${{ matrix.platform }}" in
linux-x86_64)
rustup target add x86_64-unknown-linux-musl
;;
macos-universal)
rustup target add aarch64-apple-darwin x86_64-apple-darwin
;;
esac

- name: Install musl tools
if: matrix.platform == 'linux-x86_64'
run: |
sudo apt-get update
sudo apt-get install -y musl-tools

- name: Build shield and shellsmith-mcp
shell: bash
run: |
set -euo pipefail
mkdir -p bundle/bin
case "${{ matrix.platform }}" in
linux-x86_64)
cargo build --release -p shield-cli -p shellsmith-mcp \
--target x86_64-unknown-linux-musl
cp target/x86_64-unknown-linux-musl/release/shield bundle/bin/shield
cp target/x86_64-unknown-linux-musl/release/shellsmith-mcp bundle/bin/shellsmith-mcp
;;
macos-universal)
cargo build --release -p shield-cli -p shellsmith-mcp --target aarch64-apple-darwin
cargo build --release -p shield-cli -p shellsmith-mcp --target x86_64-apple-darwin
for binary in shield shellsmith-mcp; do
lipo -create \
"target/aarch64-apple-darwin/release/$binary" \
"target/x86_64-apple-darwin/release/$binary" \
-output "bundle/bin/$binary"
done
;;
windows-x86_64)
cargo build --release -p shield-cli -p shellsmith-mcp \
--target x86_64-pc-windows-msvc
cp target/x86_64-pc-windows-msvc/release/shield.exe bundle/bin/shield.exe
cp target/x86_64-pc-windows-msvc/release/shellsmith-mcp.exe bundle/bin/shellsmith-mcp.exe
;;
*)
echo "unknown platform: ${{ matrix.platform }}" >&2
exit 1
;;
esac

- name: Download Android runtime asset
shell: bash
run: |
set -euo pipefail
mkdir -p runtime
gh release download "$TAG" \
--repo "$GITHUB_REPOSITORY" \
--pattern "*android-runtime.zip" \
--dir runtime
archive="$(find runtime -maxdepth 1 -type f -name '*android-runtime.zip' -print -quit)"
test -n "$archive"
echo "RUNTIME_ARCHIVE=$archive" >> "$GITHUB_ENV"

- name: Extract Android runtime asset
shell: bash
run: |
set -euo pipefail
PYTHON=python3
command -v python3 >/dev/null 2>&1 || PYTHON=python
"$PYTHON" - "$RUNTIME_ARCHIVE" <<'PY'
import sys, zipfile
with zipfile.ZipFile(sys.argv[1]) as archive:
archive.extractall("runtime/extracted")
PY

- name: Assemble CLI + MCP bundle
shell: bash
run: |
set -euo pipefail
mkdir -p bundle/lib bundle/resources bundle/licenses
runtime_root="$(dirname "$(find runtime/extracted -type f -name resources.zip -print -quit)")"
test -n "$runtime_root"
cp "$runtime_root"/resources*.zip bundle/resources/
cp "$runtime_root"/mocika-play-delivery-api.jar bundle/resources/
cp "$runtime_root"/xop-pvm2-packer.jar bundle/lib/
cp "$runtime_root"/licenses/* bundle/licenses/ 2>/dev/null || true
cp tools/apktool_3.0.3.jar bundle/lib/apktool.jar
cp tools/apksigner.jar bundle/lib/apksigner.jar
cp tools/licenses/XopProtector-LICENSE.txt bundle/licenses/
cp tools/licenses/XopProtector-NOTICE.txt bundle/licenses/

cat > bundle/README.md <<EOF
# Shellsmith CLI + MCP ${VERSION}

Platform: ${PLATFORM}

This bundle contains the `shield` CLI and the `shellsmith-mcp` stdio server.
No artifact, certificate or password is uploaded by this package.

## Verify

\`\`\`bash
./bin/shield --version
./bin/shellsmith-mcp --version
\`\`\`

## MCP client configuration

Use an absolute path to the extracted bundle:

\`\`\`json
{
"mcpServers": {
"shellsmith": {
"command": "/absolute/path/to/bin/shellsmith-mcp",
"args": ["--allow-root", "/absolute/path/to/projects"]
}
}
}
\`\`\`

`shellsmith-mcp` automatically finds the sibling `shield` binary. If you keep
the CLI elsewhere, pass `--cli /path/to/shield` or set
`SHELLSMITH_MCP_CLI=/path/to/shield`.

Keystore passwords are read from the MCP server environment only:

\`\`\`bash
export MOCIKA_SHIELD_KS_PASS="..."
export MOCIKA_SHIELD_KEY_PASS="..."
\`\`\`

Do not put a plaintext password in the MCP client JSON or tool arguments.

## Requirements

- Java 17+ with \`java\` and \`keytool\` available.
- AAB workflows also need \`bundletool.jar\` and \`aapt2\`; pass their paths to the tool.
- Android APK/AAB work stays on this machine; iOS archive requires macOS and Xcode.
- macOS: if Gatekeeper blocks the unsigned CLI, run
\`xattr -rd com.apple.quarantine /path/to/Shellsmith-cli-*\`.

See \`docs/mcp.md\` in the repository for the full tool list and security model.
EOF

- name: Archive and checksum
shell: bash
run: |
set -euo pipefail
mkdir -p dist
package="Shellsmith-cli-${VERSION}-${PLATFORM}"
mv bundle "$package"
if [[ "$RUNNER_OS" == "Windows" ]]; then
powershell -NoProfile -Command "Compress-Archive -Path '$package' -DestinationPath '$package.zip' -Force"
archive="$package.zip"
else
tar -czf "$package.tar.gz" "$package"
archive="$package.tar.gz"
fi
PYTHON=python3
command -v python3 >/dev/null 2>&1 || PYTHON=python
"$PYTHON" - "$archive" > "$archive.sha256" <<'PY'
import hashlib, pathlib, sys
path = pathlib.Path(sys.argv[1])
print(f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}")
PY
mv "$archive" "$archive.sha256" dist/
echo "RELEASE_ARCHIVE=$archive" >> "$GITHUB_ENV"
echo "RELEASE_CHECKSUM=$archive.sha256" >> "$GITHUB_ENV"

- name: Upload release assets
shell: bash
run: |
set -euo pipefail
gh release upload "$TAG" \
"dist/$RELEASE_ARCHIVE" \
"dist/$RELEASE_CHECKSUM" \
--repo "$GITHUB_REPOSITORY" \
--clobber
33 changes: 24 additions & 9 deletions apps/shellsmith-mcp/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,9 @@ use std::sync::Arc;
#[derive(Debug, Parser)]
#[command(name = "shellsmith-mcp", version, about)]
struct Args {
/// shellsmith/shield CLI 可执行文件。
#[arg(
long,
env = "SHELLSMITH_MCP_CLI",
value_name = "PATH",
default_value = "shield"
)]
cli: PathBuf,
/// shellsmith/shield CLI 可执行文件;默认先找同目录的 shield,再找 PATH。
#[arg(long, env = "SHELLSMITH_MCP_CLI", value_name = "PATH")]
cli: Option<PathBuf>,

/// 允许工具读写的工作目录;可重复。未配置时不限制路径。
#[arg(long = "allow-root", value_name = "DIR")]
Expand All @@ -45,6 +40,26 @@ fn run() -> Result<()> {
.into_iter()
.map(|root| root.canonicalize().unwrap_or(root))
.collect();
let server = Arc::new(jobs::Server::new(args.cli, roots));
let server = Arc::new(jobs::Server::new(
args.cli.unwrap_or_else(default_cli),
roots,
));
protocol::serve(server)
}

fn default_cli() -> PathBuf {
let name = if cfg!(windows) {
"shield.exe"
} else {
"shield"
};
if let Ok(executable) = std::env::current_exe() {
if let Some(directory) = executable.parent() {
let sibling = directory.join(name);
if sibling.is_file() {
return sibling;
}
}
}
PathBuf::from(name)
}
25 changes: 24 additions & 1 deletion docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,29 @@
- 长任务:立即返回 `job_id`,通过 `shellsmith_job_status` / `shellsmith_job_result` / `shellsmith_job_cancel` 管理
- 破坏性操作:必须显式传 `confirm: true`

## 从 GitHub Releases 安装

发布页的 `Shellsmith-cli-<version>-<platform>` 包同时包含 `shield` 和 `shellsmith-mcp`。Release 发布后由 `CLI and MCP Release Bundles` 工作流自动构建并上传。

1. 下载对应平台的 `.tar.gz`(Linux/macOS)或 `.zip`(Windows)并解压;
2. 直接运行 `bin/shellsmith-mcp --allow-root /path/to/projects`;
3. 不需要额外指定 `--cli`:MCP 会优先查找同目录的 `shield`,找不到时再查 `PATH`。

MCP 客户端配置示例:

```json
{
"mcpServers": {
"shellsmith": {
"command": "/absolute/path/to/bin/shellsmith-mcp",
"args": ["--allow-root", "/absolute/path/to/projects"]
}
}
}
```

发布包中的 `README.md` 也包含同样的配置。密码仍然只从启动环境读取,不要把明文密码写进客户端 JSON。

## 构建与启动

```bash
Expand All @@ -21,7 +44,7 @@ cargo build --release -p shellsmith-mcp

| 参数 | 环境变量 | 说明 |
|---|---|---|
| `--cli PATH` | `SHELLSMITH_MCP_CLI` | `shield` 可执行文件;默认从 `PATH` 查找 `shield` |
| `--cli PATH` | `SHELLSMITH_MCP_CLI` | `shield` 可执行文件;默认先找同目录,再查 `PATH` |
| `--allow-root DIR` | `SHELLSMITH_MCP_ALLOWED_ROOTS` | 允许读写的目录,可重复;未配置时不限制路径。环境变量使用平台路径分隔符 |

服务启动后从 stdin 读取 MCP 消息,向 stdout 写响应;诊断信息只写 stderr。
Expand Down
Loading