Skip to content

Commit a2d14c1

Browse files
fentasclaudeCopilot
authored
feat: add completion, docgen, LLM schemas, and MCP server builtins (#36)
## Summary - **`:usage::completion`** — native Rust builtin for shell completion generation (bash, zsh, fish) - **`:usage::docgen`** — native Rust builtin for documentation generation (man, md, rst, yaml) - **`docgen llm`** — generate ready-to-use tool schemas for Claude, OpenAI, Gemini, and Kimi directly from `:usage`/`:args` declarations - **`:usage::mcp`** — full MCP (Model Context Protocol) server as a bash loadable builtin. Any argsh script becomes a live tool server for Claude Code, Cursor, Claude Desktop, etc. - **`argsh e2e`** — E2E integration test command that validates MCP flow via Claude CLI ## What changed ### Native builtins (Rust) - Refactored `usage.rs` into `usage/` module directory (`mod.rs`, `completion.rs`, `docgen.rs`, `mcp.rs`) - Added `docgen llm claude|openai|gemini|kimi` — JSON tool schemas with type mapping and required flags - Added MCP server (`mcp.rs`, ~640 lines): JSON-RPC 2.0 stdio loop, manual JSON parser, subprocess execution with SIGCHLD race-condition fix - Added `shell::get_script_path()` for MCP subprocess invocation - 100% code coverage (2211/2211 lines, 206 excluded via annotations) ### Documentation - New **AI Integration** sidebar category (`docs/ai/`) - `index.mdx` — overview of static schemas vs live MCP - `mcp.mdx` — MCP server usage, client config (Claude Code/Desktop/Cursor), protocol details - Moved `clis-for-llms.mdx` into `ai/` category - Updated `README.md` with `:usage::mcp` in builtin table - Updated `docgen.mdx` cross-reference ### CLI tooling - `argsh e2e [--script ./app.sh] [--model haiku]` — validates MCP tools via `claude --print --mcp-config` ### Tests - 18 MCP protocol tests (initialize, ping, tools/list, tools/call, error handling) - 11 docgen LLM tests (claude, openai, gemini, kimi, subcommands, no-subcommands) - Fixture `mcp_test.sh` for subprocess-based tools/call tests - All existing tests pass (217 total in builtin mode) ## Test plan - [x] `cargo clippy -- -D warnings` — clean - [x] `argsh coverage builtin` — 217 tests, 100% coverage - [x] `argsh test` — non-builtin mode unaffected - [x] `argsh e2e` — Claude discovers and invokes MCP tools end-to-end 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent bdfa089 commit a2d14c1

29 files changed

Lines changed: 3502 additions & 325 deletions

.bin/argsh

Lines changed: 131 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
set -euo pipefail
44

55
: "${PATH_BASE:="$(git rev-parse --show-toplevel)"}"
6+
: "${PATH_BIN:="${PATH_BASE}/.bin"}"
67
: "${ARGSH_SOURCE:="argsh"}"; export ARGSH_SOURCE
78
: "${MIN_COVERAGE:="70"}"
89

@@ -85,15 +86,20 @@ _test() {
8586
BATS_LOAD="argsh.min.sh"
8687
export BATS_LOAD
8788
}
89+
8890
case "${target}" in
8991
all)
90-
ARGSH_BUILTIN_TEST=1 argsh::main test libraries .docker/test
92+
ARGSH_BUILTIN_TEST=1 \
93+
argsh::main test libraries .docker/test
9194
if command -v cargo &>/dev/null; then
9295
test::minifier
9396
fi
9497
;;
9598
argsh) argsh::main test libraries .docker/test ;;
96-
builtin) ARGSH_BUILTIN_TEST=1 argsh::main test libraries .docker/test ;;
99+
builtin)
100+
ARGSH_BUILTIN_TEST=1 \
101+
argsh::main test libraries .docker/test
102+
;;
97103
minifier) test::minifier ;;
98104
*)
99105
echo "Unknown target: ${target}. Use: all, argsh, builtin, minifier" >&2
@@ -209,16 +215,24 @@ coverage::builtin() {
209215
rustup component add llvm-tools
210216
}
211217

212-
# Build with coverage instrumentation (override release profile settings that break coverage)
213-
echo "Building with coverage instrumentation..."
214-
RUSTFLAGS="-C instrument-coverage" \
215-
CARGO_PROFILE_RELEASE_STRIP="none" \
216-
CARGO_PROFILE_RELEASE_LTO="false" \
217-
CARGO_PROFILE_RELEASE_PANIC="unwind" \
218-
cargo build --release \
219-
--manifest-path "${PATH_BASE}/builtin/Cargo.toml"
218+
# Build coverage-instrumented .so inside Docker (rust:1-slim-bookworm) to ensure
219+
# glibc compatibility with the kcov/kcov test container (also Debian bookworm).
220+
# Mount sources at the same absolute path so llvm-cov finds them on the host.
221+
# Share cargo registry cache to avoid re-downloading dependencies.
222+
echo "Building with coverage instrumentation (via Docker)..."
223+
mkdir -p "${HOME}/.cargo/registry"
224+
docker run --rm \
225+
--user "$(id -u):$(id -g)" \
226+
-v "${PATH_BASE}/builtin:${PATH_BASE}/builtin" \
227+
-v "${HOME}/.cargo/registry:/usr/local/cargo/registry" \
228+
-w "${PATH_BASE}/builtin" \
229+
rust:1-slim-bookworm \
230+
bash -c 'RUSTFLAGS="-C instrument-coverage" \
231+
CARGO_PROFILE_RELEASE_STRIP=none \
232+
CARGO_PROFILE_RELEASE_LTO=false \
233+
CARGO_PROFILE_RELEASE_PANIC=unwind \
234+
cargo build --release'
220235

221-
rm -f "${PATH_BIN}/argsh.so" && cp "${so_path}" "${PATH_BIN}/argsh.so"
222236
rm -rf "${cov_dir}"
223237
mkdir -p "${cov_dir}"
224238

@@ -239,17 +253,17 @@ coverage::builtin() {
239253
"${llvm_tools}/llvm-cov" report \
240254
--instr-profile="${cov_dir}/coverage.profdata" \
241255
"${so_path}" \
242-
--ignore-filename-regex='\.cargo/registry' \
243-
--ignore-filename-regex='\.rustup/toolchains' \
256+
--ignore-filename-regex='cargo/registry' \
257+
--ignore-filename-regex='rustup/toolchains' \
244258
--ignore-filename-regex='^/rustc/'
245259

246260
# Export JSON and generate builtin/coverage.json
247261
local _cov_tmp; _cov_tmp="$(mktemp)"
248262
"${llvm_tools}/llvm-cov" export \
249263
--instr-profile="${cov_dir}/coverage.profdata" \
250264
"${so_path}" \
251-
--ignore-filename-regex='\.cargo/registry' \
252-
--ignore-filename-regex='\.rustup/toolchains' \
265+
--ignore-filename-regex='cargo/registry' \
266+
--ignore-filename-regex='rustup/toolchains' \
253267
--ignore-filename-regex='^/rustc/' \
254268
--summary-only > "${_cov_tmp}"
255269

@@ -528,15 +542,28 @@ _coverage() {
528542
}
529543

530544
###
531-
### all (minify + lint + coverage + commit)
545+
### ci (minify + lint + coverage + commit)
532546
###
533-
_all() {
547+
_ci() {
534548
local message="regenerate files"
535549
local -a args=(
536550
'message|m' "Commit message"
537551
)
538552
:args "Minify, lint, coverage, then commit regenerated files" "${@}"
539553

554+
if [[ -n "$(git -C "${PATH_BASE}" status --porcelain)" ]]; then
555+
echo "Warning: repository has uncommitted changes." >&2
556+
git -C "${PATH_BASE}" status --short >&2
557+
echo >&2
558+
if [[ -t 0 ]]; then
559+
read -rp "Continue anyway? [y/N] " answer
560+
[[ "${answer}" =~ ^[Yy] ]] || { echo "Aborted." >&2; return 1; }
561+
else
562+
echo "Non-interactive mode — aborting. Commit or stash changes first." >&2
563+
return 1
564+
fi
565+
fi
566+
540567
minify::argsh
541568
_lint
542569
_lint -m
@@ -546,27 +573,105 @@ _all() {
546573
git -C "${PATH_BASE}" commit -m "${message}" --no-gpg-sign
547574
}
548575

576+
###
577+
### e2e (manual integration tests using claude CLI)
578+
###
579+
_e2e() {
580+
local script model budget
581+
# shellcheck disable=SC2016
582+
local prompt='You have MCP tools from "test-app". Call the serve tool with verbose set to true. Reply with ONLY the raw tool output.'
583+
local -a args=(
584+
'script|s' "Script to test (default: built-in fixture)"
585+
'model|m' "Claude model (default: haiku)"
586+
'budget|b' "Max budget in USD (default: 0.05)"
587+
'prompt' "Custom prompt for Claude"
588+
)
589+
:args "E2E MCP test: validates tool discovery + invocation via Claude CLI" "${@}"
590+
591+
: "${model:=haiku}"
592+
: "${budget:=0.05}"
593+
: "${script:="${PATH_BASE}/libraries/fixtures/args/mcp_test.sh"}"
594+
595+
binary::exists claude || {
596+
echo "claude CLI required. Install: https://docs.anthropic.com/en/docs/claude-code"
597+
return 1
598+
} >&2
599+
script="$(cd "$(dirname "${script}")" && pwd)/$(basename "${script}")"
600+
[[ -x "${script}" ]] || { echo "Script not executable: ${script}" >&2; return 1; }
601+
602+
# Ensure builtin .so
603+
[[ -f "${PATH_BIN}/argsh.so" ]] || {
604+
local release="${PATH_BASE}/builtin/target/release/libargsh.so"
605+
[[ -f "${release}" ]] || { echo "builtin .so not found — run: argsh build builtin" >&2; return 1; }
606+
cp "${release}" "${PATH_BIN}/argsh.so"
607+
}
608+
609+
echo "Pre-flight: testing ${script} in CLI mode..."
610+
local out
611+
out=$("${script}" --help 2>&1) || { echo "Script --help failed" >&2; return 1; }
612+
echo " OK — help text works"
613+
614+
# Build MCP config (JSON-escape the script path)
615+
local mcp_config escaped_script
616+
escaped_script="${script//\\/\\\\}"
617+
escaped_script="${escaped_script//\"/\\\"}"
618+
mcp_config=$(printf '{"mcpServers":{"test-app":{"type":"stdio","command":"%s","args":["mcp"]}}}' "${escaped_script}")
619+
620+
echo ""
621+
echo "Running: claude --print --model ${model} --max-budget-usd ${budget}"
622+
echo "MCP server: ${script} mcp"
623+
echo ""
624+
625+
local result
626+
result=$(claude \
627+
--print \
628+
--model "${model}" \
629+
--max-budget-usd "${budget}" \
630+
--mcp-config "${mcp_config}" \
631+
--allowedTools "mcp__test-app__*" \
632+
--permission-mode "bypassPermissions" \
633+
--no-session-persistence \
634+
-p "${prompt}" \
635+
2>/dev/null) || { echo "claude CLI failed" >&2; return 1; }
636+
637+
echo "Claude response:"
638+
echo "${result}"
639+
echo "---"
640+
641+
local pass=0 total=2
642+
if echo "${result}" | grep -qi "serving"; then echo "PASS: contains 'serving'"; ((++pass)); else echo "FAIL: missing 'serving'"; fi
643+
if echo "${result}" | grep -qi "verbose"; then echo "PASS: contains 'verbose'"; ((++pass)); else echo "FAIL: missing 'verbose'"; fi
644+
645+
echo ""
646+
echo "Results: ${pass}/${total}"
647+
[[ "${pass}" -eq "${total}" ]] || return 1
648+
}
649+
549650
###
550651
### main
551652
###
552653
argsh::main() {
553654
local tty=""
554-
argsh::docker &>/dev/null || {
555-
echo "Docker build failed. Run 'argsh docker' to see errors." >&2
556-
return 1
557-
}
655+
local _image="${ARGSH_DOCKER_IMAGE:-ghcr.io/arg-sh/argsh:latest}"
656+
657+
if [[ -z "${ARGSH_DOCKER_IMAGE:-}" ]]; then
658+
argsh::docker &>/dev/null || {
659+
echo "Docker build failed. Run 'argsh docker' to see errors." >&2
660+
return 1
661+
}
662+
fi
558663

559664
[[ ! -t 1 ]] || tty="-it"
560665
# shellcheck disable=SC2046
561666
docker run --rm ${tty} $(docker::user) -w /workspace \
562667
-e "BATS_LOAD" \
563668
-e "ARGSH_SOURCE" \
564669
-e "ARGSH_BUILTIN_TEST" \
565-
-e "ARGSH_BUILTIN_PATH" \
670+
${ARGSH_BUILTIN_PATH:+-e "ARGSH_BUILTIN_PATH=${ARGSH_BUILTIN_PATH}"} \
566671
-e "LLVM_PROFILE_FILE" \
567672
-e "GIT_COMMIT_SHA=$(git rev-parse HEAD 2>/dev/null || :)" \
568673
-e "GIT_VERSION=$(git describe --tags --dirty 2>/dev/null || :)" \
569-
ghcr.io/arg-sh/argsh:latest "${@}"
674+
"${_image}" "${@}"
570675
}
571676

572677
_main() {
@@ -585,7 +690,7 @@ _main() {
585690
local -a usage
586691
usage=(
587692
- "Commands"
588-
'all:-_all' "Minify, lint, coverage, commit regenerated files"
693+
'ci|all:-_ci' "Minify, lint, coverage, commit regenerated files"
589694
'build:-_build' "Build release binaries [all|builtin|minifier]"
590695
'lint:-_lint' "Run linters [all|argsh|builtin|minifier]"
591696
'test:-_test' "Run tests [all|argsh|builtin|minifier]"
@@ -596,6 +701,8 @@ _main() {
596701
'docs-lint:-lint::docs' "Run documentation linters"
597702
'docs-test:-test::docs' "Run documentation tests"
598703
'vale:-lint::vale' "Run vale"
704+
- "Integration"
705+
'e2e:-_e2e' "E2E MCP test via Claude CLI [--script ./app.sh]"
599706
- "Additional commands"
600707
'docker:-argsh::docker' "Build docker image"
601708
)

.dockerignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,6 @@
33
!.docker
44
!argsh.min.sh
55
!minifier
6-
!builtin
6+
!builtin
7+
builtin/target
8+
minifier/target

.github/workflows/argsh.yaml

Lines changed: 11 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: argsh
22
on:
33
push:
44
branches:
5-
- master
5+
- main
66
tags:
77
- "v*"
88
pull_request:
@@ -17,7 +17,7 @@ on:
1717

1818
concurrency:
1919
group: ${{ github.workflow }}-${{ github.ref }}
20-
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
20+
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
2121

2222
defaults:
2323
run:
@@ -33,31 +33,16 @@ jobs:
3333
uses: actions/checkout@v4
3434
- name: Direnv
3535
uses: HatsuneMiku3939/direnv-action@v1
36-
- name: Setup Rust
37-
uses: dtolnay/rust-toolchain@stable
38-
with:
39-
components: clippy
40-
- name: Cache cargo
41-
uses: actions/cache@v4
42-
with:
43-
path: |
44-
builtin/target
45-
minifier/target
46-
~/.cargo/registry
47-
key: ${{ runner.os }}-cargo-all-${{ hashFiles('**/Cargo.lock') }}
48-
restore-keys: |
49-
${{ runner.os }}-cargo-all-
50-
${{ runner.os }}-cargo-
5136
- name: Lint
5237
run: argsh lint
5338
- name: Test
5439
run: argsh test
55-
- name: Build builtin release
56-
working-directory: builtin
57-
run: cargo build --release
58-
- name: Build minifier release
59-
working-directory: minifier
60-
run: cargo build --release
40+
- name: Extract artifacts from Docker
41+
run: |
42+
mkdir -p builtin/target/release minifier/target/release
43+
docker run --rm --entrypoint cat ghcr.io/arg-sh/argsh:latest /usr/local/lib/argsh.so > builtin/target/release/libargsh.so
44+
docker run --rm --entrypoint cat ghcr.io/arg-sh/argsh:latest /usr/local/bin/minifier > minifier/target/release/minifier
45+
chmod +x minifier/target/release/minifier
6146
- name: Upload builtin library
6247
uses: actions/upload-artifact@v4
6348
with:
@@ -81,20 +66,6 @@ jobs:
8166
uses: actions/checkout@v4
8267
- name: Direnv
8368
uses: HatsuneMiku3939/direnv-action@v1
84-
- name: Setup Rust
85-
uses: dtolnay/rust-toolchain@stable
86-
with:
87-
components: llvm-tools
88-
- name: Cache cargo
89-
uses: actions/cache@v4
90-
with:
91-
path: |
92-
builtin/target
93-
~/.cargo/registry
94-
key: ${{ runner.os }}-cargo-cov-${{ hashFiles('builtin/Cargo.lock') }}
95-
restore-keys: |
96-
${{ runner.os }}-cargo-cov-
97-
${{ runner.os }}-cargo-
9869
- name: Coverage
9970
run: argsh coverage builtin
10071
- name: Is insync
@@ -111,20 +82,6 @@ jobs:
11182
uses: actions/checkout@v4
11283
- name: Direnv
11384
uses: HatsuneMiku3939/direnv-action@v1
114-
- name: Setup Rust
115-
uses: dtolnay/rust-toolchain@stable
116-
with:
117-
components: llvm-tools
118-
- name: Cache cargo
119-
uses: actions/cache@v4
120-
with:
121-
path: |
122-
minifier/target
123-
~/.cargo/registry
124-
key: ${{ runner.os }}-cargo-mincov-${{ hashFiles('minifier/Cargo.lock') }}
125-
restore-keys: |
126-
${{ runner.os }}-cargo-mincov-
127-
${{ runner.os }}-cargo-
12885
- name: Coverage
12986
run: argsh coverage minifier
13087
- name: Is insync
@@ -196,9 +153,11 @@ jobs:
196153
trap "rm -f '${_t}'" EXIT
197154
base64 -d <<< "${__ARGSH_SO_B64}" > "${_t}" || return 1
198155
# shellcheck disable=SC2229
199-
enable -f "${_t}" :usage :args \
156+
enable -f "${_t}" \
157+
:usage :usage::help :usage::completion :usage::docgen :usage::mcp :args \
200158
is::array is::uninitialized is::set is::tty \
201159
args::field_name to::int to::float to::boolean to::file to::string \
160+
import import::clear \
202161
2>/dev/null || return 1
203162
rm -f "${_t}"
204163
return 0

Dockerfile

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
# All the tools required to run the tests, lint and coverage Bash scripts
22

33
# minify — build Rust minifier
4-
FROM rust:1-slim AS minifier-build
4+
FROM rust:1-slim-bookworm AS minifier-build
55
WORKDIR /build
66
COPY minifier/ .
77
RUN cargo build --release
88

99
# builtin — build Rust loadable builtins
10-
FROM rust:1-slim AS builtin-build
10+
FROM rust:1-slim-bookworm AS builtin-build
11+
ARG RUSTFLAGS
12+
ARG CARGO_PROFILE_RELEASE_STRIP
13+
ARG CARGO_PROFILE_RELEASE_LTO
14+
ARG CARGO_PROFILE_RELEASE_PANIC
1115
WORKDIR /build
1216
COPY builtin/ .
1317
RUN cargo build --release

0 commit comments

Comments
 (0)