@celsoft/coordinator 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/README.md +107 -0
- package/THIRD_PARTY_NOTICES.md +2005 -0
- package/agents/console.md +20 -0
- package/agents/free.md +110 -0
- package/build/0-JP6XL6LB.js +17 -0
- package/build/1-A52YZWNS.js +17 -0
- package/build/2-CLH2NM6F.js +32 -0
- package/build/3-4N467IB6.js +45 -0
- package/build/4-WL5H4LUW.js +29 -0
- package/build/_layout.svelte-KPUSX7LQ.js +27 -0
- package/build/_page.svelte-22H56UR6.js +7972 -0
- package/build/_page.svelte-CVWUNRQC.js +135 -0
- package/build/_page.svelte-LTZXBHMT.js +57 -0
- package/build/_server.ts-3KVBYN6P.js +20 -0
- package/build/_server.ts-3POPKVEM.js +47 -0
- package/build/_server.ts-5735T2V5.js +31 -0
- package/build/_server.ts-5VXGNWV2.js +38 -0
- package/build/_server.ts-645KP7WP.js +42 -0
- package/build/_server.ts-A3AA24Z2.js +28 -0
- package/build/_server.ts-CITUOEM6.js +58 -0
- package/build/_server.ts-I7HMTPOA.js +25 -0
- package/build/_server.ts-M5BQ4LLX.js +30 -0
- package/build/_server.ts-OB4O27HF.js +683 -0
- package/build/_server.ts-OJT7HE7M.js +15 -0
- package/build/_server.ts-OYGWVLZM.js +12 -0
- package/build/_server.ts-T75UVMQP.js +38 -0
- package/build/_server.ts-WYZYNHUZ.js +28 -0
- package/build/_server.ts-Z26EB47B.js +486 -0
- package/build/chunk-4CIG53L5.js +33 -0
- package/build/chunk-BJTJS3XY.js +338 -0
- package/build/chunk-F2CNWJZM.js +14 -0
- package/build/chunk-HGYE3UTU.js +416 -0
- package/build/chunk-KAYW2W7H.js +1311 -0
- package/build/chunk-LFP3UPCM.js +10 -0
- package/build/chunk-M44JEFEH.js +62 -0
- package/build/chunk-MC5AGLMY.js +39 -0
- package/build/chunk-NCZ77J5E.js +19798 -0
- package/build/chunk-NVNLDPUJ.js +2122 -0
- package/build/chunk-PDCON4KS.js +494 -0
- package/build/chunk-Q4OQNY5M.js +25 -0
- package/build/chunk-QCUOR2U5.js +75 -0
- package/build/chunk-R6RYZZWA.js +46 -0
- package/build/chunk-SOJTASZC.js +51 -0
- package/build/chunk-SU26PJVX.js +410 -0
- package/build/chunk-U5BSBG4R.js +506 -0
- package/build/chunk-UEKOZGRO.js +37 -0
- package/build/chunk-W6EFUWJ2.js +9 -0
- package/build/chunk-WOPREK6Q.js +117 -0
- package/build/chunk-WWJ3F4QS.js +127 -0
- package/build/chunk-ZGZJEEE3.js +1091 -0
- package/build/client/_app/immutable/assets/0.BGa5ZNDM.css +1 -0
- package/build/client/_app/immutable/chunks/BUrrLYDt.js +2 -0
- package/build/client/_app/immutable/chunks/BgFr_EyZ.js +4 -0
- package/build/client/_app/immutable/chunks/Bjy-W4x2.js +82 -0
- package/build/client/_app/immutable/chunks/C8qZab0y.js +56 -0
- package/build/client/_app/immutable/chunks/C90fLsA5.js +4 -0
- package/build/client/_app/immutable/chunks/CRzLMJPS.js +1 -0
- package/build/client/_app/immutable/chunks/DLT1iIlz.js +2 -0
- package/build/client/_app/immutable/chunks/pIrVVOVY.js +2 -0
- package/build/client/_app/immutable/entry/app.0DERehLW.js +3 -0
- package/build/client/_app/immutable/entry/payload.DSmR2FwN.js +2 -0
- package/build/client/_app/immutable/entry/start.B7srFiqh.js +3 -0
- package/build/client/_app/immutable/nodes/0.aD9vJoxR.js +2 -0
- package/build/client/_app/immutable/nodes/1.Br4FL91i.js +2 -0
- package/build/client/_app/immutable/nodes/2.vl6OYfu3.js +8 -0
- package/build/client/_app/immutable/nodes/3.VYqJOLAF.js +2 -0
- package/build/client/_app/immutable/nodes/4.jF9UVlFN.js +2 -0
- package/build/client/_app/version.json +1 -0
- package/build/client/apple-touch-icon.png +0 -0
- package/build/client/favicon.ico +0 -0
- package/build/client/favicon.svg +48 -0
- package/build/client/icon-192.png +0 -0
- package/build/client/icon-512.png +0 -0
- package/build/client/icon-maskable-512.png +0 -0
- package/build/client/icon.svg +44 -0
- package/build/client/manifest.webmanifest +15 -0
- package/build/error.svelte-JXZDY65F.js +51 -0
- package/build/handler.js +716 -0
- package/build/hooks.server-34IMHRKC.js +43 -0
- package/build/instance-NP3FJIWW.js +2967 -0
- package/client/client.mjs +28 -0
- package/client/hooks.mjs +7 -0
- package/client/register.mjs +5 -0
- package/dist/cli.js +62826 -0
- package/dist/port.js +36691 -0
- package/package.json +20 -1
- package/skills/agents/.claude-plugin/plugin.json +5 -0
- package/skills/agents/hooks/hooks.json +1 -0
- package/skills/agents/hooks/register.ts +57 -0
- package/skills/agents/skills/bus-work/SKILL.md +66 -0
- package/skills/agents/skills/plan/SKILL.md +98 -0
- package/skills/console/.claude-plugin/plugin.json +5 -0
- package/skills/console/hooks/hooks.json +1 -0
- package/skills/console/hooks/register.ts +10 -0
- package/skills/console/skills/bus/SKILL.md +61 -0
- package/skills/default/.claude-plugin/plugin.json +5 -0
- package/skills/default/skills/git-workflow/SKILL.md +294 -0
- package/skills/default/skills/git-workflow/mise-tasks/_lib.sh +107 -0
- package/skills/default/skills/git-workflow/mise-tasks/clone +36 -0
- package/skills/default/skills/git-workflow/mise-tasks/repo-list +8 -0
- package/skills/default/skills/git-workflow/mise-tasks/unpushed +26 -0
- package/skills/default/skills/git-workflow/mise-tasks/worktree-add +60 -0
- package/skills/default/skills/github/SKILL.md +208 -0
- package/skills/default/skills/github/scripts/gh-org +196 -0
- package/skills/default/skills/plan-page/SKILL.md +300 -0
- package/skills/default/skills/plan-page/assets/index.html +854 -0
- package/skills/default/skills/plan-page/scripts/inline +46 -0
- package/skills/default/skills/plan-page/scripts/stage +26 -0
- package/skills/default/skills/secrets/SKILL.md +134 -0
- package/skills/default/skills/secrets/scripts/secrets-check +87 -0
- package/skills/default/skills/writing-style/SKILL.md +45 -0
- package/skills/extra/.claude-plugin/plugin.json +5 -0
- package/skills/extra/skills/artifacts/SKILL.md +52 -0
- package/skills/extra/skills/github-actions/SKILL.md +78 -0
- package/skills/extra/skills/github-actions/scan +32 -0
- package/skills/extra/skills/markdown-review/SKILL.md +70 -0
- package/skills/extra/skills/markdown-review/assets/index.html +201 -0
- package/skills/extra/skills/markdown-review/scripts/stage +36 -0
- package/skills/extra/skills/mise-usage/SKILL.md +155 -0
- package/skills/extra/skills/plugin-authoring/SKILL.md +317 -0
- package/skills/extra/skills/project-conventions/SKILL.md +64 -0
- package/skills/extra/skills/questions/SKILL.md +156 -0
- package/skills/extra/skills/questions/assets/index.html +376 -0
- package/skills/extra/skills/questions/scripts/stage +26 -0
- package/skills/extra/skills/serve/SKILL.md +165 -0
- package/skills/extra/skills/serve/scripts/serve +268 -0
- package/skills/extra/skills/serve/scripts/server.py +345 -0
- package/skills/extra/skills/skill-dependencies/SKILL.md +65 -0
- package/skills/extra/skills/skill-maintenance/SKILL.md +101 -0
- package/skills/extra/skills/ticketing/SKILL.md +125 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# serve: start, list, and stop throwaway local HTTP servers.
|
|
4
|
+
#
|
|
5
|
+
# One python3 process per server, detached, with live reload, an idle timeout,
|
|
6
|
+
# and a request log. State lives in one JSON file per server so `list` and
|
|
7
|
+
# `stop` work from any session.
|
|
8
|
+
|
|
9
|
+
set -euo pipefail
|
|
10
|
+
|
|
11
|
+
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
12
|
+
SERVER="$HERE/server.py"
|
|
13
|
+
STATE_DIR="$HOME/.cache/claude-serve"
|
|
14
|
+
|
|
15
|
+
die() { echo "FAIL: $*" >&2; exit 1; }
|
|
16
|
+
|
|
17
|
+
usage() {
|
|
18
|
+
cat <<'USAGE'
|
|
19
|
+
Usage:
|
|
20
|
+
serve start <file-or-dir> [--allow GLOB]... [--idle MIN]
|
|
21
|
+
[--no-reload] [--bind WHERE]
|
|
22
|
+
serve list
|
|
23
|
+
serve stop <id|port|all>
|
|
24
|
+
serve log <id|port> [-n LINES]
|
|
25
|
+
serve hosts
|
|
26
|
+
|
|
27
|
+
--allow takes a glob relative to the served root; repeat it for more.
|
|
28
|
+
Without any --allow the whole tree is served, which can leak secrets.
|
|
29
|
+
--bind is local (default), lan, tailscale, or a literal address.
|
|
30
|
+
The port is chosen automatically (first free from 8000 up) and reported in the URL.
|
|
31
|
+
Defaults: live reload on, 30 min idle timeout.
|
|
32
|
+
USAGE
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
py() { command -v python3 >/dev/null 2>&1 || die "python3 not found"; }
|
|
36
|
+
|
|
37
|
+
# Print the state file matching an id or port, or nothing.
|
|
38
|
+
find_state() {
|
|
39
|
+
local want="$1" f
|
|
40
|
+
for f in "$STATE_DIR"/*.json; do
|
|
41
|
+
[ -e "$f" ] || continue
|
|
42
|
+
if [ "$(basename "$f" .json)" = "$want" ]; then echo "$f"; return; fi
|
|
43
|
+
if [ "$(field "$f" port)" = "$want" ]; then echo "$f"; return; fi
|
|
44
|
+
done
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
field() {
|
|
48
|
+
python3 - "$1" "$2" <<'PY'
|
|
49
|
+
import json, sys
|
|
50
|
+
try:
|
|
51
|
+
v = json.load(open(sys.argv[1])).get(sys.argv[2], "")
|
|
52
|
+
print(", ".join(v) if isinstance(v, list) else v)
|
|
53
|
+
except Exception:
|
|
54
|
+
print("")
|
|
55
|
+
PY
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
# Drop state files whose process is gone, and the logs left behind with them.
|
|
59
|
+
reap() {
|
|
60
|
+
local f pid id
|
|
61
|
+
for f in "$STATE_DIR"/*.json; do
|
|
62
|
+
[ -e "$f" ] || continue
|
|
63
|
+
pid="$(field "$f" pid)"
|
|
64
|
+
if [ -z "$pid" ] || ! kill -0 "$pid" 2>/dev/null; then rm -f "$f"; fi
|
|
65
|
+
done
|
|
66
|
+
# A log outlives its server so `serve log` still works right after a stop.
|
|
67
|
+
# Anything older than a day has no reader left.
|
|
68
|
+
for f in "$STATE_DIR"/*.log; do
|
|
69
|
+
[ -e "$f" ] || continue
|
|
70
|
+
id="$(basename "$f" .log)"
|
|
71
|
+
[ -e "$STATE_DIR/$id.json" ] && continue
|
|
72
|
+
find "$f" -mtime +1 -delete 2>/dev/null || true
|
|
73
|
+
done
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# --- bind targets -----------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
default_iface() {
|
|
79
|
+
route -n get default 2>/dev/null | awk '/interface:/{print $2; exit}'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
lan_ip() {
|
|
83
|
+
local iface
|
|
84
|
+
iface="$(default_iface)"
|
|
85
|
+
if [ -n "$iface" ]; then
|
|
86
|
+
ipconfig getifaddr "$iface" 2>/dev/null && return
|
|
87
|
+
fi
|
|
88
|
+
ip -4 route get 1.1.1.1 2>/dev/null | awk '/src/{print $7; exit}'
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
ts_bin() {
|
|
92
|
+
command -v tailscale 2>/dev/null && return
|
|
93
|
+
[ -x /Applications/Tailscale.app/Contents/MacOS/Tailscale ] \
|
|
94
|
+
&& echo /Applications/Tailscale.app/Contents/MacOS/Tailscale
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
ts_ip() {
|
|
98
|
+
local bin
|
|
99
|
+
bin="$(ts_bin)" || return 0
|
|
100
|
+
[ -n "$bin" ] || return 0
|
|
101
|
+
"$bin" ip -4 2>/dev/null | head -1
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
ts_name() {
|
|
105
|
+
local bin
|
|
106
|
+
bin="$(ts_bin)" || return 0
|
|
107
|
+
[ -n "$bin" ] || return 0
|
|
108
|
+
"$bin" status --json 2>/dev/null | python3 -c '
|
|
109
|
+
import json, sys
|
|
110
|
+
try:
|
|
111
|
+
print(json.load(sys.stdin).get("Self", {}).get("DNSName", "").rstrip("."))
|
|
112
|
+
except Exception:
|
|
113
|
+
pass
|
|
114
|
+
' 2>/dev/null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
# Resolve a bind choice to "ADDR LABEL". Empty output means unavailable.
|
|
118
|
+
resolve_bind() {
|
|
119
|
+
local choice="$1" addr label
|
|
120
|
+
case "$choice" in
|
|
121
|
+
local|localhost) echo "127.0.0.1 localhost" ;;
|
|
122
|
+
lan|host)
|
|
123
|
+
addr="$(lan_ip)"
|
|
124
|
+
[ -n "$addr" ] && echo "$addr $addr"
|
|
125
|
+
;;
|
|
126
|
+
tailscale|ts)
|
|
127
|
+
addr="$(ts_ip)"
|
|
128
|
+
[ -n "$addr" ] || return 0
|
|
129
|
+
label="$(ts_name)"
|
|
130
|
+
echo "$addr ${label:-$addr}"
|
|
131
|
+
;;
|
|
132
|
+
*) echo "$choice $choice" ;;
|
|
133
|
+
esac
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
cmd_hosts() {
|
|
137
|
+
local r
|
|
138
|
+
r="$(resolve_bind local)"; echo "local: $r"
|
|
139
|
+
r="$(resolve_bind lan)"; echo "lan: ${r:-UNAVAILABLE}"
|
|
140
|
+
r="$(resolve_bind tailscale)"; echo "tailscale: ${r:-UNAVAILABLE}"
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
cmd_start() {
|
|
144
|
+
py
|
|
145
|
+
local target="" idle=30 bind=local noreload=""
|
|
146
|
+
local -a allows
|
|
147
|
+
allows=()
|
|
148
|
+
while [ $# -gt 0 ]; do
|
|
149
|
+
case "$1" in
|
|
150
|
+
--idle) idle="$2"; shift 2 ;;
|
|
151
|
+
--no-reload) noreload="--no-reload"; shift ;;
|
|
152
|
+
--bind) bind="$2"; shift 2 ;;
|
|
153
|
+
--allow) allows+=("--allow" "$2"); shift 2 ;;
|
|
154
|
+
-h|--help) usage; exit 0 ;;
|
|
155
|
+
-*) die "unknown flag $1" ;;
|
|
156
|
+
*) target="$1"; shift ;;
|
|
157
|
+
esac
|
|
158
|
+
done
|
|
159
|
+
[ -n "$target" ] || die "no path given"
|
|
160
|
+
[ -e "$target" ] || die "no such path: $target"
|
|
161
|
+
|
|
162
|
+
local resolved host label
|
|
163
|
+
resolved="$(resolve_bind "$bind")"
|
|
164
|
+
[ -n "$resolved" ] || die "bind target '$bind' is unavailable; run: serve hosts"
|
|
165
|
+
host="${resolved%% *}"
|
|
166
|
+
label="${resolved##* }"
|
|
167
|
+
|
|
168
|
+
local root entry
|
|
169
|
+
if [ -d "$target" ]; then
|
|
170
|
+
root="$(cd "$target" && pwd)"
|
|
171
|
+
entry="/"
|
|
172
|
+
else
|
|
173
|
+
root="$(cd "$(dirname "$target")" && pwd)"
|
|
174
|
+
entry="/$(basename "$target")"
|
|
175
|
+
[ ${#allows[@]} -eq 0 ] && allows=("--allow" "$(basename "$target")")
|
|
176
|
+
fi
|
|
177
|
+
|
|
178
|
+
mkdir -p "$STATE_DIR"
|
|
179
|
+
reap
|
|
180
|
+
local id state log
|
|
181
|
+
id="$(python3 -c 'import secrets; print(secrets.token_hex(4))')"
|
|
182
|
+
state="$STATE_DIR/$id.json"
|
|
183
|
+
log="$STATE_DIR/$id.log"
|
|
184
|
+
: > "$log"
|
|
185
|
+
|
|
186
|
+
nohup python3 "$SERVER" \
|
|
187
|
+
--root "$root" --state "$state" --log "$log" --entry "$entry" \
|
|
188
|
+
--idle "$idle" --host "$host" --label "$label" "${allows[@]}" $noreload \
|
|
189
|
+
>>"$log" 2>&1 &
|
|
190
|
+
disown 2>/dev/null || true
|
|
191
|
+
|
|
192
|
+
local i=0
|
|
193
|
+
while [ $i -lt 60 ]; do
|
|
194
|
+
[ -s "$state" ] && break
|
|
195
|
+
sleep 0.1
|
|
196
|
+
i=$((i + 1))
|
|
197
|
+
done
|
|
198
|
+
[ -s "$state" ] || { echo "--- log ---"; tail -20 "$log"; die "server did not start"; }
|
|
199
|
+
|
|
200
|
+
echo "URL: $(field "$state" url)"
|
|
201
|
+
echo "ID: $id"
|
|
202
|
+
echo "ROOT: $root"
|
|
203
|
+
echo "RELOAD: $(field "$state" reload)"
|
|
204
|
+
echo "BIND: $host"
|
|
205
|
+
echo "ALLOW: $(field "$state" allow)"
|
|
206
|
+
echo "MATCHED: $(field "$state" matched) file(s)"
|
|
207
|
+
if [ ${#allows[@]} -eq 0 ]; then
|
|
208
|
+
echo "WARNING: no --allow given; the entire tree under ROOT is served."
|
|
209
|
+
fi
|
|
210
|
+
echo "IDLE_MINUTES: $idle"
|
|
211
|
+
echo "LOG: $log"
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
cmd_list() {
|
|
215
|
+
mkdir -p "$STATE_DIR"
|
|
216
|
+
reap
|
|
217
|
+
local f found=0
|
|
218
|
+
for f in "$STATE_DIR"/*.json; do
|
|
219
|
+
[ -e "$f" ] || continue
|
|
220
|
+
found=1
|
|
221
|
+
echo "$(basename "$f" .json) $(field "$f" url) $(field "$f" root)"
|
|
222
|
+
done
|
|
223
|
+
[ "$found" = 1 ] || echo "NONE"
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
cmd_stop() {
|
|
227
|
+
[ $# -ge 1 ] || die "stop needs an id, a port, or 'all'"
|
|
228
|
+
mkdir -p "$STATE_DIR"
|
|
229
|
+
if [ "$1" = "all" ]; then
|
|
230
|
+
local f pid stopped=0
|
|
231
|
+
for f in "$STATE_DIR"/*.json; do
|
|
232
|
+
[ -e "$f" ] || continue
|
|
233
|
+
pid="$(field "$f" pid)"
|
|
234
|
+
[ -n "$pid" ] && kill "$pid" 2>/dev/null || true
|
|
235
|
+
rm -f "$f"
|
|
236
|
+
stopped=$((stopped + 1))
|
|
237
|
+
done
|
|
238
|
+
echo "STOPPED: $stopped"
|
|
239
|
+
return
|
|
240
|
+
fi
|
|
241
|
+
local state pid
|
|
242
|
+
state="$(find_state "$1")"
|
|
243
|
+
[ -n "$state" ] || die "no server matching $1"
|
|
244
|
+
pid="$(field "$state" pid)"
|
|
245
|
+
[ -n "$pid" ] && kill "$pid" 2>/dev/null || true
|
|
246
|
+
rm -f "$state"
|
|
247
|
+
echo "STOPPED: $1"
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
cmd_log() {
|
|
251
|
+
[ $# -ge 1 ] || die "log needs an id or a port"
|
|
252
|
+
local state lines=40
|
|
253
|
+
state="$(find_state "$1")"
|
|
254
|
+
[ -n "$state" ] || die "no server matching $1"
|
|
255
|
+
shift
|
|
256
|
+
[ "${1:-}" = "-n" ] && lines="$2"
|
|
257
|
+
tail -n "$lines" "$(field "$state" log)"
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
case "${1:-}" in
|
|
261
|
+
start) shift; cmd_start "$@" ;;
|
|
262
|
+
list) shift; cmd_list "$@" ;;
|
|
263
|
+
stop) shift; cmd_stop "$@" ;;
|
|
264
|
+
log) shift; cmd_log "$@" ;;
|
|
265
|
+
hosts) shift; cmd_hosts "$@" ;;
|
|
266
|
+
-h|--help|"") usage ;;
|
|
267
|
+
*) usage; exit 1 ;;
|
|
268
|
+
esac
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Static file server with live reload, an idle timeout, and a request log.
|
|
3
|
+
|
|
4
|
+
Only files matching an explicit allowlist of glob patterns are served, listed,
|
|
5
|
+
or watched. Patterns are relative to the served root and are re-evaluated per
|
|
6
|
+
request, so a file created later that matches an existing pattern is served
|
|
7
|
+
without a restart.
|
|
8
|
+
|
|
9
|
+
Run by the `serve` wrapper, never directly. Binds, then writes its state file so
|
|
10
|
+
the wrapper can report the port it actually got.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import atexit
|
|
15
|
+
import errno
|
|
16
|
+
import glob
|
|
17
|
+
import html
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import signal
|
|
21
|
+
import sys
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
from functools import partial
|
|
25
|
+
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
26
|
+
from urllib.parse import parse_qs, unquote, urlparse
|
|
27
|
+
|
|
28
|
+
MAX_MATCHES = 20000
|
|
29
|
+
POLL_HOLD = 25.0
|
|
30
|
+
|
|
31
|
+
state = {}
|
|
32
|
+
last_request = time.monotonic()
|
|
33
|
+
_token = ("", 0.0)
|
|
34
|
+
_token_lock = threading.Lock()
|
|
35
|
+
|
|
36
|
+
RELOAD_JS = """
|
|
37
|
+
<script>(function(){var v=null;function poll(){fetch('/__reload'+(v?'?v='+encodeURIComponent(v):''),{cache:'no-store'})
|
|
38
|
+
.then(function(r){return r.json()}).then(function(d){if(v===null){v=d.v;return poll()}
|
|
39
|
+
if(d.v!==v){location.reload();return}poll()}).catch(function(){setTimeout(poll,1500)})}poll()})();</script>
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Allowlist:
|
|
44
|
+
"""The set of files this server will serve, from stdlib glob patterns.
|
|
45
|
+
|
|
46
|
+
Expanding the patterns (rather than matching a request path against them)
|
|
47
|
+
means `**`, `*`, `?` and character classes behave exactly as glob does, with
|
|
48
|
+
no pattern semantics reimplemented here. The expansion is cached briefly so
|
|
49
|
+
a burst of requests for one page costs one filesystem scan, and refreshes
|
|
50
|
+
often enough that a newly created matching file appears on its own.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
TTL = 1.0
|
|
54
|
+
|
|
55
|
+
def __init__(self, root, patterns):
|
|
56
|
+
self.root = root
|
|
57
|
+
self.real_root = os.path.realpath(root)
|
|
58
|
+
self.patterns = patterns
|
|
59
|
+
self._files = frozenset()
|
|
60
|
+
self._stamp = 0.0
|
|
61
|
+
self._lock = threading.Lock()
|
|
62
|
+
|
|
63
|
+
def _expand(self):
|
|
64
|
+
found = set()
|
|
65
|
+
for pattern in self.patterns:
|
|
66
|
+
target = os.path.join(glob.escape(self.root), pattern)
|
|
67
|
+
for hit in glob.iglob(target, recursive=True):
|
|
68
|
+
if not os.path.isfile(hit):
|
|
69
|
+
continue
|
|
70
|
+
# Patterns are relative to the root, so a match that resolves
|
|
71
|
+
# outside it (via a symlink) is not what was asked for.
|
|
72
|
+
real = os.path.realpath(hit)
|
|
73
|
+
if not real.startswith(self.real_root + os.sep):
|
|
74
|
+
continue
|
|
75
|
+
found.add(os.path.relpath(hit, self.root))
|
|
76
|
+
if len(found) >= MAX_MATCHES:
|
|
77
|
+
return found
|
|
78
|
+
return found
|
|
79
|
+
|
|
80
|
+
def files(self):
|
|
81
|
+
now = time.monotonic()
|
|
82
|
+
with self._lock:
|
|
83
|
+
if self._files is not None and now - self._stamp < self.TTL:
|
|
84
|
+
return self._files
|
|
85
|
+
expanded = frozenset(self._expand())
|
|
86
|
+
with self._lock:
|
|
87
|
+
self._files = expanded
|
|
88
|
+
self._stamp = now
|
|
89
|
+
return expanded
|
|
90
|
+
|
|
91
|
+
def allows(self, rel):
|
|
92
|
+
return rel in self.files()
|
|
93
|
+
|
|
94
|
+
def under(self, rel):
|
|
95
|
+
"""Relative paths allowed beneath a directory, for listings."""
|
|
96
|
+
prefix = "" if rel in (".", "") else rel.rstrip("/") + "/"
|
|
97
|
+
return sorted(f for f in self.files() if f.startswith(prefix))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def snapshot(allowlist):
|
|
101
|
+
latest = 0.0
|
|
102
|
+
count = 0
|
|
103
|
+
for rel in allowlist.files():
|
|
104
|
+
try:
|
|
105
|
+
mtime = os.stat(os.path.join(allowlist.root, rel)).st_mtime
|
|
106
|
+
except OSError:
|
|
107
|
+
continue
|
|
108
|
+
latest = max(latest, mtime)
|
|
109
|
+
count += 1
|
|
110
|
+
return "%.3f:%d" % (latest, count)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def token(allowlist):
|
|
114
|
+
global _token
|
|
115
|
+
with _token_lock:
|
|
116
|
+
value, stamp = _token
|
|
117
|
+
now = time.monotonic()
|
|
118
|
+
if not value or now - stamp > 0.3:
|
|
119
|
+
value = snapshot(allowlist)
|
|
120
|
+
_token = (value, now)
|
|
121
|
+
return value
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def inject(body):
|
|
125
|
+
lower = body.lower()
|
|
126
|
+
idx = lower.rfind(b"</body>")
|
|
127
|
+
if idx == -1:
|
|
128
|
+
return body + RELOAD_JS.encode()
|
|
129
|
+
return body[:idx] + RELOAD_JS.encode() + body[idx:]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class Handler(SimpleHTTPRequestHandler):
|
|
133
|
+
protocol_version = "HTTP/1.1"
|
|
134
|
+
|
|
135
|
+
def __init__(self, *args, root=None, reload_enabled=True, log_path=None,
|
|
136
|
+
allowlist=None, **kwargs):
|
|
137
|
+
self.root = root
|
|
138
|
+
self.reload_enabled = reload_enabled
|
|
139
|
+
self.log_path = log_path
|
|
140
|
+
self.allowlist = allowlist
|
|
141
|
+
super().__init__(*args, directory=root, **kwargs)
|
|
142
|
+
|
|
143
|
+
def log_message(self, fmt, *args):
|
|
144
|
+
line = "%s %s\n" % (time.strftime("%H:%M:%S"), fmt % args)
|
|
145
|
+
try:
|
|
146
|
+
with open(self.log_path, "a") as fh:
|
|
147
|
+
fh.write(line)
|
|
148
|
+
except OSError:
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
def log_request(self, code="-", size="-"):
|
|
152
|
+
if urlparse(self.path).path == "/__reload":
|
|
153
|
+
return
|
|
154
|
+
super().log_request(code, size)
|
|
155
|
+
|
|
156
|
+
def rel_for(self, url_path):
|
|
157
|
+
"""Requested URL path as a root-relative path, or None if it escapes."""
|
|
158
|
+
clean = unquote(urlparse(url_path).path)
|
|
159
|
+
joined = os.path.normpath(os.path.join(self.root, clean.lstrip("/")))
|
|
160
|
+
try:
|
|
161
|
+
rel = os.path.relpath(joined, self.root)
|
|
162
|
+
except ValueError:
|
|
163
|
+
return None
|
|
164
|
+
if rel == ".." or rel.startswith(".." + os.sep):
|
|
165
|
+
return None
|
|
166
|
+
return rel
|
|
167
|
+
|
|
168
|
+
def do_GET(self):
|
|
169
|
+
global last_request
|
|
170
|
+
last_request = time.monotonic()
|
|
171
|
+
if urlparse(self.path).path == "/__reload":
|
|
172
|
+
return self.serve_reload()
|
|
173
|
+
|
|
174
|
+
rel = self.rel_for(self.path)
|
|
175
|
+
if rel is None:
|
|
176
|
+
return self.send_error(404, "Not Found")
|
|
177
|
+
|
|
178
|
+
if self.allowlist.allows(rel):
|
|
179
|
+
target = os.path.join(self.root, rel)
|
|
180
|
+
if self.reload_enabled and rel.endswith((".html", ".htm")):
|
|
181
|
+
return self.serve_html(target)
|
|
182
|
+
return self.serve_file(target)
|
|
183
|
+
|
|
184
|
+
# A directory-ish request: fall back to its index, else list what is
|
|
185
|
+
# allowed beneath it.
|
|
186
|
+
for index in ("index.html", "index.htm"):
|
|
187
|
+
candidate = index if rel in (".", "") else rel.rstrip("/") + "/" + index
|
|
188
|
+
if self.allowlist.allows(candidate):
|
|
189
|
+
return self.serve_html(os.path.join(self.root, candidate))
|
|
190
|
+
under = self.allowlist.under(rel)
|
|
191
|
+
if under:
|
|
192
|
+
return self.list_allowed(rel, under)
|
|
193
|
+
|
|
194
|
+
self.log_message('BLOCKED "%s" (no allow pattern matches)', self.path)
|
|
195
|
+
return self.send_error(404, "Not Found")
|
|
196
|
+
|
|
197
|
+
def do_HEAD(self):
|
|
198
|
+
global last_request
|
|
199
|
+
last_request = time.monotonic()
|
|
200
|
+
rel = self.rel_for(self.path)
|
|
201
|
+
if rel is None or not self.allowlist.allows(rel):
|
|
202
|
+
return self.send_error(404, "Not Found")
|
|
203
|
+
return super().do_HEAD()
|
|
204
|
+
|
|
205
|
+
def send_body(self, body, ctype):
|
|
206
|
+
self.send_response(200)
|
|
207
|
+
self.send_header("Content-Type", ctype)
|
|
208
|
+
self.send_header("Content-Length", str(len(body)))
|
|
209
|
+
self.send_header("Cache-Control", "no-store")
|
|
210
|
+
self.end_headers()
|
|
211
|
+
self.wfile.write(body)
|
|
212
|
+
|
|
213
|
+
def serve_file(self, target):
|
|
214
|
+
ctype = self.guess_type(target)
|
|
215
|
+
try:
|
|
216
|
+
with open(target, "rb") as fh:
|
|
217
|
+
body = fh.read()
|
|
218
|
+
except OSError:
|
|
219
|
+
return self.send_error(404, "Not Found")
|
|
220
|
+
self.send_body(body, ctype)
|
|
221
|
+
|
|
222
|
+
def serve_html(self, target):
|
|
223
|
+
try:
|
|
224
|
+
with open(target, "rb") as fh:
|
|
225
|
+
body = fh.read()
|
|
226
|
+
except OSError:
|
|
227
|
+
return self.send_error(404, "Not Found")
|
|
228
|
+
if self.reload_enabled:
|
|
229
|
+
body = inject(body)
|
|
230
|
+
self.send_body(body, "text/html; charset=utf-8")
|
|
231
|
+
|
|
232
|
+
def list_allowed(self, rel, under):
|
|
233
|
+
shown = html.escape(urlparse(self.path).path, quote=False)
|
|
234
|
+
base = "" if rel in (".", "") else rel.rstrip("/") + "/"
|
|
235
|
+
rows = []
|
|
236
|
+
for item in under:
|
|
237
|
+
label = html.escape(item[len(base):], quote=True)
|
|
238
|
+
rows.append('<li><a href="%s">%s</a></li>' % (label, label))
|
|
239
|
+
body = (
|
|
240
|
+
"<!doctype html><meta charset=utf-8><title>%s</title>"
|
|
241
|
+
"<h1>%s</h1><ul>%s</ul>" % (shown, shown, "".join(rows))
|
|
242
|
+
)
|
|
243
|
+
if self.reload_enabled:
|
|
244
|
+
body += RELOAD_JS
|
|
245
|
+
self.send_body(body.encode(), "text/html; charset=utf-8")
|
|
246
|
+
|
|
247
|
+
def serve_reload(self):
|
|
248
|
+
have = (parse_qs(urlparse(self.path).query).get("v") or [""])[0]
|
|
249
|
+
deadline = time.monotonic() + POLL_HOLD
|
|
250
|
+
current = token(self.allowlist)
|
|
251
|
+
while current == have and time.monotonic() < deadline:
|
|
252
|
+
time.sleep(0.4)
|
|
253
|
+
current = token(self.allowlist)
|
|
254
|
+
self.send_body(json.dumps({"v": current}).encode(), "application/json")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def bind(host, span):
|
|
258
|
+
first = 8000
|
|
259
|
+
last = first + span
|
|
260
|
+
for port in range(first, last + 1):
|
|
261
|
+
try:
|
|
262
|
+
server = ThreadingHTTPServer((host, port), lambda *a, **k: None)
|
|
263
|
+
except OSError as exc:
|
|
264
|
+
if exc.errno in (errno.EADDRINUSE, errno.EACCES):
|
|
265
|
+
continue
|
|
266
|
+
raise
|
|
267
|
+
server.server_close()
|
|
268
|
+
return port
|
|
269
|
+
raise SystemExit("FAIL: no free port in %d-%d" % (first, last))
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def watchdog(idle_seconds, server):
|
|
273
|
+
while True:
|
|
274
|
+
time.sleep(5)
|
|
275
|
+
if time.monotonic() - last_request > idle_seconds:
|
|
276
|
+
server.shutdown()
|
|
277
|
+
return
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def main():
|
|
281
|
+
ap = argparse.ArgumentParser()
|
|
282
|
+
ap.add_argument("--root", required=True)
|
|
283
|
+
ap.add_argument("--state", required=True)
|
|
284
|
+
ap.add_argument("--log", required=True)
|
|
285
|
+
ap.add_argument("--entry", default="/")
|
|
286
|
+
ap.add_argument("--allow", action="append", default=[])
|
|
287
|
+
ap.add_argument("--idle", type=float, default=30.0)
|
|
288
|
+
ap.add_argument("--host", default="127.0.0.1")
|
|
289
|
+
ap.add_argument("--label", default="")
|
|
290
|
+
ap.add_argument("--no-reload", action="store_true")
|
|
291
|
+
args = ap.parse_args()
|
|
292
|
+
|
|
293
|
+
root = os.path.abspath(args.root)
|
|
294
|
+
patterns = args.allow or ["**"]
|
|
295
|
+
allowlist = Allowlist(root, patterns)
|
|
296
|
+
port = bind(args.host, 99)
|
|
297
|
+
handler = partial(
|
|
298
|
+
Handler, root=root, reload_enabled=not args.no_reload, log_path=args.log,
|
|
299
|
+
allowlist=allowlist,
|
|
300
|
+
)
|
|
301
|
+
server = ThreadingHTTPServer((args.host, port), handler)
|
|
302
|
+
server.daemon_threads = True
|
|
303
|
+
|
|
304
|
+
host_label = args.label or args.host
|
|
305
|
+
state.update(
|
|
306
|
+
id=os.path.basename(args.state).removesuffix(".json"),
|
|
307
|
+
pid=os.getpid(),
|
|
308
|
+
port=port,
|
|
309
|
+
root=root,
|
|
310
|
+
entry=args.entry,
|
|
311
|
+
url="http://%s:%d%s" % (host_label, port, args.entry),
|
|
312
|
+
log=args.log,
|
|
313
|
+
reload=not args.no_reload,
|
|
314
|
+
allow=patterns,
|
|
315
|
+
matched=len(allowlist.files()),
|
|
316
|
+
idle_minutes=args.idle,
|
|
317
|
+
started=time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
318
|
+
)
|
|
319
|
+
with open(args.state, "w") as fh:
|
|
320
|
+
json.dump(state, fh)
|
|
321
|
+
|
|
322
|
+
def cleanup(*_):
|
|
323
|
+
try:
|
|
324
|
+
os.unlink(args.state)
|
|
325
|
+
except OSError:
|
|
326
|
+
pass
|
|
327
|
+
os._exit(0)
|
|
328
|
+
|
|
329
|
+
atexit.register(lambda: cleanup())
|
|
330
|
+
signal.signal(signal.SIGTERM, cleanup)
|
|
331
|
+
signal.signal(signal.SIGINT, cleanup)
|
|
332
|
+
|
|
333
|
+
if args.idle > 0:
|
|
334
|
+
threading.Thread(
|
|
335
|
+
target=watchdog, args=(args.idle * 60.0, server), daemon=True
|
|
336
|
+
).start()
|
|
337
|
+
|
|
338
|
+
try:
|
|
339
|
+
server.serve_forever()
|
|
340
|
+
finally:
|
|
341
|
+
cleanup()
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
if __name__ == "__main__":
|
|
345
|
+
sys.exit(main())
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: skill-dependencies
|
|
3
|
+
description: "How a skill handles a tool it needs: declare it via uv or mise so it is never missing; react to a genuine miss with one install and one retry; stop and ask rather than improvise. Read when a skill's command fails because a tool is not found."
|
|
4
|
+
verified: 2026-09-02
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Handling Missing Dependencies
|
|
8
|
+
|
|
9
|
+
Every skill in this collection defers to this skill for missing-tool handling. Skills never
|
|
10
|
+
precheck for tools and never hand-roll their own install logic.
|
|
11
|
+
|
|
12
|
+
This skill owns the **posture**: when to declare, when to react, when to stop. Where the
|
|
13
|
+
user has a skill of their own that installs a tool, follow it; otherwise use the tool's own
|
|
14
|
+
official installer, never a second way you invent.
|
|
15
|
+
|
|
16
|
+
## Core rule: assume, don't check
|
|
17
|
+
|
|
18
|
+
Assume the tools are already installed. **Run the real command directly.** Never precheck
|
|
19
|
+
(`command -v`, `which`, `--version`) before doing the work: that spends time on the normal
|
|
20
|
+
path, where everything is present.
|
|
21
|
+
|
|
22
|
+
Only **react** when a command fails because a tool is missing (`command not found`,
|
|
23
|
+
exit 127).
|
|
24
|
+
|
|
25
|
+
## Three tiers, in order
|
|
26
|
+
|
|
27
|
+
1. **Declare it. The default, always first.** `uv` (PEP 723 `dependencies`) and mise
|
|
28
|
+
(`#MISE tools={...}`) each provision what they cover, so a script's real dependencies
|
|
29
|
+
belong in its own header, checked against `mise registry` for what mise can supply. A
|
|
30
|
+
declared dependency never goes missing, which is why this tier is not really about
|
|
31
|
+
failure handling at all.
|
|
32
|
+
2. **React with one install, then one retry.** For a tool with an installer skill of its own,
|
|
33
|
+
follow it. For a script's tool dependency that `mise registry`
|
|
34
|
+
genuinely has no entry for, `brew install <formula>` and retry the original command once.
|
|
35
|
+
No approval needed here: a named formula is a known, deterministic, low-risk install.
|
|
36
|
+
3. **Stop and ask.** If the install fails, or nothing packages the tool, present the user with
|
|
37
|
+
at least one concrete option and get approval. Do not improvise: no manual download, no
|
|
38
|
+
alternate package manager, no silently skipping the step.
|
|
39
|
+
|
|
40
|
+
## Reacting to a missing tool
|
|
41
|
+
|
|
42
|
+
`uv` and `mise` are peers. Neither provisions the other, and neither substitutes for the
|
|
43
|
+
other: when a command calls `uv`, run `uv`; when it calls `mise`, run `mise`.
|
|
44
|
+
|
|
45
|
+
- **`uv` missing:** `brew install uv`, then retry the original command exactly as written.
|
|
46
|
+
- **`mise` missing:** install it from its own official installer (https://mise.jdx.dev),
|
|
47
|
+
not through Homebrew, then retry.
|
|
48
|
+
- **`brew` missing:** install it from https://brew.sh at its standard prefix, then retry.
|
|
49
|
+
- **A script's declared tool missing:** the script's own `#MISE tools={...}` header should
|
|
50
|
+
have covered it; fix the header rather than installing by hand.
|
|
51
|
+
|
|
52
|
+
If `uv run …` fails because `uv` is not found, the fix is `brew install uv` then `uv run …`
|
|
53
|
+
again. Never reach for mise to supply uv (`mise exec -- uv`, `mise use uv`), and never move a
|
|
54
|
+
failing mise command onto uv. Install the tool that is missing; run the command that was
|
|
55
|
+
written.
|
|
56
|
+
|
|
57
|
+
## Never
|
|
58
|
+
|
|
59
|
+
- Never precheck for a tool before using it.
|
|
60
|
+
- Never provide a missing tool by routing it through another; install the missing tool itself.
|
|
61
|
+
- Never substitute one tool for another; run the command that was written.
|
|
62
|
+
- Never ask the user to install something a skill can install reactively.
|
|
63
|
+
- Never use `sudo`, and never assume admin access. Where a tool genuinely needs privileges the
|
|
64
|
+
user may not have, its own skill says what to do, including when to escalate to IT.
|
|
65
|
+
- Never improvise past a failed or unavailable install. That is tier 3: stop and ask.
|