Skip to content

Commit 67593b5

Browse files
committed
chore(FR-2900): show Portless dev server status in Claude Code status line
1 parent 32dbd20 commit 67593b5

2 files changed

Lines changed: 102 additions & 1 deletion

File tree

.claude/scripts/statusline.sh

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,97 @@ if [[ -n "$WORKSPACE" ]]; then
117117
[[ -n "$VSCODE_URL" ]] && VSCODE_PART=$(link "$VSCODE_URL" "⧉ VS Code")
118118
fi
119119

120+
# ── Portless URL detection ─────────────────────────────────
121+
# Maps the current workspace to its Portless route by checking each route
122+
# process's cwd. Subdomain-based guessing breaks when multiple worktrees
123+
# run dev servers concurrently (e.g. one auto-named route can match a
124+
# different worktree's slug). cwd is the source of truth — `dev.mjs` is
125+
# spawned from the workspace, so its portless child's cwd equals it.
126+
#
127+
# Cache: pid → cwd in CACHE_DIR for 30s. Portless restarts cycle pids
128+
# quickly, so a short TTL trades minimal staleness for avoiding lsof per
129+
# tick (lsof on macOS spawns ~30–100ms per call).
130+
PORTLESS_PART=""
131+
if [[ -n "$WORKSPACE" ]] && command -v portless >/dev/null 2>&1 && command -v lsof >/dev/null 2>&1; then
132+
PORTLESS_TTL=30
133+
WS_REAL=$(python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$WORKSPACE" 2>/dev/null || printf '%s' "$WORKSPACE")
134+
PORTLESS_LIST=$(portless list 2>/dev/null || true)
135+
136+
PORTLESS_URL=""
137+
PORTLESS_NOTE=""
138+
if [[ -n "$PORTLESS_LIST" ]]; then
139+
while IFS= read -r _line; do
140+
[[ "$_line" =~ pid\ ([0-9]+) ]] || continue
141+
_pid="${BASH_REMATCH[1]}"
142+
_pid_cache="${CACHE_DIR}/portless-pid-${_pid}.cwd"
143+
_cwd=""
144+
if [[ -f "$_pid_cache" ]]; then
145+
_age=$(( $(date +%s) - $(file_mtime "$_pid_cache") ))
146+
if (( _age < PORTLESS_TTL )); then
147+
_cwd=$(cat "$_pid_cache" 2>/dev/null) || _cwd=""
148+
fi
149+
fi
150+
if [[ -z "$_cwd" ]]; then
151+
_cwd=$(lsof -p "$_pid" -a -d cwd -Fn 2>/dev/null | awk '/^n/{print substr($0,2); exit}' || true)
152+
if [[ -n "$_cwd" ]]; then
153+
printf '%s' "$_cwd" > "$_pid_cache" 2>/dev/null || true
154+
fi
155+
fi
156+
[[ -z "$_cwd" ]] && continue
157+
_cwd_real=$(python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$_cwd" 2>/dev/null || printf '%s' "$_cwd")
158+
if [[ "$_cwd_real" == "$WS_REAL" ]]; then
159+
PORTLESS_URL=$(printf '%s' "$_line" | grep -oE 'https?://[a-z0-9-]+\.localhost:[0-9]+' | head -1 || true)
160+
break
161+
fi
162+
done <<< "$PORTLESS_LIST"
163+
fi
164+
165+
# Fallback: portless route not registered (e.g. lost via the 0.10.x race
166+
# where a dying prior owner's onCleanup wipes the new owner's entry).
167+
# Scan for any `portless` CLI process whose cwd matches this workspace —
168+
# if found, the dev server IS running, just unrouted. Derive subdomain
169+
# from argv (explicit `portless <name>`) or package.json (auto-name).
170+
if [[ -z "$PORTLESS_URL" ]]; then
171+
for _pid in $(pgrep -f 'portless/dist/cli\.js' 2>/dev/null || true); do
172+
_cwd=$(lsof -p "$_pid" -a -d cwd -Fn 2>/dev/null | awk '/^n/{print substr($0,2); exit}' || true)
173+
[[ -z "$_cwd" ]] && continue
174+
_cwd_real=$(python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$_cwd" 2>/dev/null || printf '%s' "$_cwd")
175+
[[ "$_cwd_real" != "$WS_REAL" ]] && continue
176+
_argv=$(ps -o command= -p "$_pid" 2>/dev/null || true)
177+
# Skip the proxy daemon itself (`portless proxy start ...`)
178+
[[ "$_argv" =~ cli\.js[[:space:]]+proxy ]] && continue
179+
_sub=$(printf '%s' "$_argv" | grep -oE 'cli\.js[[:space:]]+[a-zA-Z0-9_.-]+' | awk '{print $2}' | head -1 || true)
180+
if [[ -z "$_sub" || "$_sub" == "run" ]]; then
181+
if [[ -f "$WS_REAL/package.json" ]]; then
182+
_sub=$(python3 - "$WS_REAL/package.json" <<'PYEOF' 2>/dev/null || true
183+
import json, re, sys
184+
try:
185+
name = json.load(open(sys.argv[1])).get("name", "") or ""
186+
name = re.sub(r"[^a-z0-9-]+", "-", name.lower())
187+
name = re.sub(r"-+", "-", name).strip("-")
188+
print(name)
189+
except Exception:
190+
print("")
191+
PYEOF
192+
)
193+
fi
194+
fi
195+
if [[ -n "$_sub" ]]; then
196+
PORTLESS_URL="https://${_sub}.localhost:1355"
197+
PORTLESS_NOTE=" (route lost)"
198+
break
199+
fi
200+
done
201+
fi
202+
203+
if [[ -n "$PORTLESS_URL" ]]; then
204+
PORTLESS_LABEL=$(printf '\033[32mPortless\033[0m')
205+
PORTLESS_PART=$(link "$PORTLESS_URL" "$PORTLESS_LABEL")
206+
else
207+
PORTLESS_PART=$(printf '\033[90mPortless\033[0m')
208+
fi
209+
fi
210+
120211
# Fallback output when there is no Jira/Teams context:
121212
# line 1 = worktree info + VS Code link (if available), line 2 = model/tokens.
122213
emit_fallback() {
@@ -128,6 +219,10 @@ emit_fallback() {
128219
[[ -n "$line1" ]] && line1+=" "
129220
line1+="$VSCODE_PART"
130221
fi
222+
if [[ -n "${PORTLESS_PART:-}" ]]; then
223+
[[ -n "$line1" ]] && line1+=" "
224+
line1+="$PORTLESS_PART"
225+
fi
131226
if [[ -n "$line1" ]]; then
132227
printf '%b\n%b' "$line1" "$MODEL_PART"
133228
else
@@ -253,6 +348,11 @@ if [[ -n "$VSCODE_PART" ]]; then
253348
LINE1+=" "
254349
fi
255350

351+
if [[ -n "${PORTLESS_PART:-}" ]]; then
352+
LINE1+="$PORTLESS_PART"
353+
LINE1+=" "
354+
fi
355+
256356
if [[ -n "$TEAMS_URL" ]]; then
257357
LINE1+=$(link "$TEAMS_URL" "Teams")
258358
LINE1+=" "

.claude/settings.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@
3030
"statusLine": {
3131
"type": "command",
3232
"command": ".claude/scripts/statusline.sh",
33-
"padding": 0
33+
"padding": 0,
34+
"refreshInterval": 10
3435
},
3536
"enableAllProjectMcpServers": true,
3637
"enabledMcpjsonServers": [

0 commit comments

Comments
 (0)