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
8 changes: 1 addition & 7 deletions .mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,7 @@
"mcpServers": {
"hwip": {
"command": "sh",
"args": [
"-c",
"if command -v hwip >/dev/null 2>&1; then exec hwip mcp; else exec npx -y @hwip/cli mcp; fi"
],
"env": {
"HWIP_API_KEY": "${HWIP_API_KEY:-}"
}
"args": ["${CLAUDE_PLUGIN_ROOT}/scripts/hwip-mcp.sh"]
}
}
}
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,9 +199,10 @@ hwipit/plugin (Claude Code plugin)
├─ hooks/hwip-approve.sh → PreToolUse(Bash): opt-in phone approval for hard-to-undo commands
├─ hooks/hwip-remote.sh → UserPromptSubmit: phone-sent message → REMOTE mode
├─ hooks/hwip-herdr.sh → agent state → herdr sidebar, for a `hwip cc` pane
├─ .mcp.json → registers the MCP server (`hwip mcp`, or `npx -y @hwip/cli mcp`
│ when `hwip` is not on PATH); `HWIP_API_KEY` is optional
│ (`${HWIP_API_KEY:-}` — unset, the key comes from ~/.hwip/config.json)
├─ .mcp.json → registers the MCP server via scripts/hwip-mcp.sh (`hwip mcp`,
│ or `npx -y @hwip/cli@<pinned version> mcp` when `hwip` is not on
│ PATH); the CLI reads `HWIP_API_KEY` from your environment, else
│ `apiKey` in ~/.hwip/config.json
└─ builds on:
├─ @hwip/cli → hooks + notify/list/dismiss + tmux remote control
└─ @hwip/mcp-server → hwip_ask / notify / clipboard / file …
Expand Down
2 changes: 1 addition & 1 deletion docs/HOOKS-EXPLAINED.md
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,7 @@ Check for errors:
```bash
# If the hook silently failed, check:
# 1. Is hwip CLI installed?
command -v hwip || echo "npx -y @hwip/cli"
command -v hwip || echo "npx -y @hwip/cli@<pin in hooks/gate.sh>"

# 2. Is HWIP_API_KEY set?
echo $HWIP_API_KEY
Expand Down
7 changes: 5 additions & 2 deletions hooks/gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -572,16 +572,19 @@ hwip_probe_cmd() {

# hwip_cli_command [seconds] — HWIP_CLI, an ARRAY: the bounded command line that
# starts the Hwip CLI. Call it as `"${HWIP_CLI[@]}" notify --title=…`. The CLI is
# `hwip` when it is on PATH, else `npx -y @hwip/cli` (three words). It used to be
# `hwip` when it is on PATH, else `npx -y @hwip/cli@<HWIP_CLI_PIN>` (three words —
# the plugin directory blocks an unpinned launcher; keep the pin equal to
# scripts/hwip-mcp.sh, which tests/test-mcp-launch.sh checks). It used to be
# one string that the hooks left unquoted, so a `hwip` under `/Users/Jane Doe/…`
# split into two words and the CLI was never started: no push, and an approval
# that could only be denied.
HWIP_CLI_PIN=0.1.19
hwip_cli_command() {
local bin
if bin=$(command -v hwip 2>/dev/null) && [ -n "$bin" ]; then
HWIP_CLI=("$bin")
else
HWIP_CLI=(npx -y @hwip/cli)
HWIP_CLI=(npx -y "@hwip/cli@$HWIP_CLI_PIN")
fi
hwip_bound_prefix "${1:-8}"
HWIP_CLI=(${HWIP_BOUND[@]+"${HWIP_BOUND[@]}"} "${HWIP_CLI[@]}")
Expand Down
14 changes: 14 additions & 0 deletions scripts/hwip-mcp.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#!/bin/sh
# Starts the plugin's MCP server (.mcp.json runs this file).
#
# A global `hwip` keeps the in-process `hwip mcp` server, on the version the
# user installed. Without one (an `npx @hwip/cli install` user) it falls back
# to npx, pinned to an exact version: the plugin directory blocks an unpinned
# launcher. Keep HWIP_CLI_VERSION equal to the pin in hooks/gate.sh
# (tests/test-mcp-launch.sh checks it) and raise both with each @hwip/cli release.
#
# The API key is not passed here. The CLI reads HWIP_API_KEY from the
# environment Claude Code passes down, else apiKey in ~/.hwip/config.json.
HWIP_CLI_VERSION=0.1.19
command -v hwip >/dev/null 2>&1 && exec hwip mcp
exec npx -y "@hwip/cli@$HWIP_CLI_VERSION" mcp
6 changes: 4 additions & 2 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ Wall-clock cases, so the suite sleeps for real (~25 s): a stub `tmux` that answe
| Test | Catches |
|------|---------|
| `a hwip whose path holds a space` | The hooks kept the CLI as one string and split it again, so `/Users/Jane Doe/…/hwip` started `/Users/Jane`: no push ever left, and every approval was denied. Now an array (`hwip_cli_command`), checked for the approval, stop and ask hooks. |
| `no hwip, so npx -y @hwip/cli` | The fallback stays three words (`-y`, `@hwip/cli`) after the change to an array. |
| `no hwip, so npx -y @hwip/cli@<pin>` | The fallback stays three words (`-y`, `@hwip/cli@<exact version>`) after the change to an array. |
| `the CLI cannot be started` | The approval hook's deny reason follows what the shell saw: exit 126/127 "Could not start the hwip CLI", 124 "did not finish", another code names it; only a CLI that ran and said nothing gets the 75 s message. |
| `hwip_remote_active reads a time that bash would take for octal` | A leading 0, 13+ digits and a future time. The first used to stop the shell with "value too great for base"; the last kept REMOTE live until the clock caught up. |
| `the denied-question stamp and the phone-origin marker` / `the idle time ioreg reports` | The other places that subtract a number a file or a probe gave them go through `hwip_age_sec` too. |
Expand Down Expand Up @@ -167,7 +167,9 @@ Wall-clock cases, so the suite sleeps for real (~25 s): a stub `tmux` that answe
| Test | Catches |
|------|---------|
| `hwip on PATH → hwip mcp` | A global install keeps the in-process `hwip mcp` server. |
| `no hwip on PATH → npx -y @hwip/cli mcp` | An `npx @hwip/cli install` user has no `hwip` binary; a bare `"command": "hwip"` never started the MCP server. Stubbed `hwip`/`npx` on a private PATH. |
| `no hwip on PATH → npx -y @hwip/cli@<pin> mcp` | An `npx @hwip/cli install` user has no `hwip` binary; a bare `"command": "hwip"` never started the MCP server. Stubbed `hwip`/`npx` on a private PATH. |
| `the npx pin is an exact version` · `the MCP pin equals the hooks pin` | The plugin directory blocks an unpinned launcher; `scripts/hwip-mcp.sh` and `hooks/gate.sh` must not drift apart. |
| `no env block` | The plugin directory holds a plugin that forwards a credential from the user's environment; the CLI reads `HWIP_API_KEY` itself. |

## Fixtures

Expand Down
4 changes: 2 additions & 2 deletions tests/test-hook-cli-and-state.sh
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ assert "the push is sent" called
assert "it carries the question" grep -qF 'Proceed?' "$WORK/argv"

echo
echo "[P27: no hwip, so npx -y @hwip/cli — still three words, not one]"
echo "[P27: no hwip, so npx -y @hwip/cli@<pin> — still three words, not one]"
NPXDIR="$WORK/npxdir"; mkdir -p "$NPXDIR"
cat > "$NPXDIR/npx" <<EOF
#!/bin/bash
Expand All @@ -113,7 +113,7 @@ chmod +x "$NPXDIR/npx"
rm -f "$WORK/argv"
OUT=$(run hwip-approve.sh "$NPXDIR:$MIN" "$RM")
assert "npx is called" called
assert "the first two words are -y @hwip/cli" bash -c '[ "$(sed -n 1p "$1")" = -y ] && [ "$(sed -n 2p "$1")" = @hwip/cli ] && [ "$(sed -n 3p "$1")" = ask ]' _ "$WORK/argv"
assert "the words are -y @hwip/cli@<pin> ask" bash -c '[ "$(sed -n 1p "$1")" = -y ] && [ "$(sed -n 2p "$1")" = "@hwip/cli@$2" ] && [ "$(sed -n 3p "$1")" = ask ]' _ "$WORK/argv" "$(sed -n 's/^HWIP_CLI_PIN=//p' "$SCRIPT_DIR/../hooks/gate.sh")"
assert "allows on approval" decision_is allow

echo
Expand Down
22 changes: 19 additions & 3 deletions tests/test-mcp-launch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
# install` as a notifications-only setup, which leaves no `hwip` on PATH. A bare
# `"command": "hwip"` then fails to spawn and the plugin's MCP server never
# starts. The launch line must use `hwip` when it exists and fall back to
# `npx -y @hwip/cli` when it does not.
# `npx -y @hwip/cli@<pin>` when it does not. The pin must be an exact version
# (the plugin directory blocks an unpinned launcher) and equal to the one the
# hooks use (hooks/gate.sh HWIP_CLI_PIN).
#
# Both binaries are stubs on a private PATH — no real CLI is ever run.

Expand Down Expand Up @@ -38,7 +40,8 @@ assert_eq() {
LAUNCH=()
while IFS= read -r -d '' part; do LAUNCH+=("$part"); done < <(node -e '
const s = require(process.argv[1]).mcpServers.hwip;
process.stdout.write([s.command, ...s.args].map((a) => a + "\0").join(""));
const root = require("path").dirname(process.argv[1]);
process.stdout.write([s.command, ...s.args].map((a) => a.replaceAll("${CLAUDE_PLUGIN_ROOT}", root) + "\0").join(""));
' "$MCP_JSON")

TMP=$(mktemp -d)
Expand All @@ -61,8 +64,21 @@ done
OUT=$(PATH="$TMP/with:/usr/bin:/bin" "${LAUNCH[@]}")
assert_eq "hwip on PATH → hwip mcp" "hwip mcp" "$OUT"

PIN=$(sed -n 's/^HWIP_CLI_VERSION=//p' "$SCRIPT_DIR/../scripts/hwip-mcp.sh")
GATE_PIN=$(sed -n 's/^HWIP_CLI_PIN=//p' "$SCRIPT_DIR/../hooks/gate.sh")
TOTAL=$((TOTAL + 1))
if printf '%s' "$PIN" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo " ✓ the npx pin is an exact version ($PIN)"; PASS=$((PASS + 1))
else
echo " ✗ the npx pin is an exact version (got '$PIN')"; FAIL=$((FAIL + 1)); FAILED_TESTS+=("exact pin")
fi
assert_eq "the MCP pin equals the hooks pin" "$GATE_PIN" "$PIN"

OUT=$(PATH="$TMP/without:/usr/bin:/bin" "${LAUNCH[@]}")
assert_eq "no hwip on PATH → npx -y @hwip/cli mcp" "npx -y @hwip/cli mcp" "$OUT"
assert_eq "no hwip on PATH → npx -y @hwip/cli@<pin> mcp" "npx -y @hwip/cli@$PIN mcp" "$OUT"

assert_eq "no env block — the key is not read from .mcp.json" "undefined" \
"$(node -e 'console.log(typeof require(process.argv[1]).mcpServers.hwip.env)' "$MCP_JSON")"

echo
echo "MCP launch: $PASS/$TOTAL passed"
Expand Down
Loading