@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.
Files changed (131) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +107 -0
  3. package/THIRD_PARTY_NOTICES.md +2005 -0
  4. package/agents/console.md +20 -0
  5. package/agents/free.md +110 -0
  6. package/build/0-JP6XL6LB.js +17 -0
  7. package/build/1-A52YZWNS.js +17 -0
  8. package/build/2-CLH2NM6F.js +32 -0
  9. package/build/3-4N467IB6.js +45 -0
  10. package/build/4-WL5H4LUW.js +29 -0
  11. package/build/_layout.svelte-KPUSX7LQ.js +27 -0
  12. package/build/_page.svelte-22H56UR6.js +7972 -0
  13. package/build/_page.svelte-CVWUNRQC.js +135 -0
  14. package/build/_page.svelte-LTZXBHMT.js +57 -0
  15. package/build/_server.ts-3KVBYN6P.js +20 -0
  16. package/build/_server.ts-3POPKVEM.js +47 -0
  17. package/build/_server.ts-5735T2V5.js +31 -0
  18. package/build/_server.ts-5VXGNWV2.js +38 -0
  19. package/build/_server.ts-645KP7WP.js +42 -0
  20. package/build/_server.ts-A3AA24Z2.js +28 -0
  21. package/build/_server.ts-CITUOEM6.js +58 -0
  22. package/build/_server.ts-I7HMTPOA.js +25 -0
  23. package/build/_server.ts-M5BQ4LLX.js +30 -0
  24. package/build/_server.ts-OB4O27HF.js +683 -0
  25. package/build/_server.ts-OJT7HE7M.js +15 -0
  26. package/build/_server.ts-OYGWVLZM.js +12 -0
  27. package/build/_server.ts-T75UVMQP.js +38 -0
  28. package/build/_server.ts-WYZYNHUZ.js +28 -0
  29. package/build/_server.ts-Z26EB47B.js +486 -0
  30. package/build/chunk-4CIG53L5.js +33 -0
  31. package/build/chunk-BJTJS3XY.js +338 -0
  32. package/build/chunk-F2CNWJZM.js +14 -0
  33. package/build/chunk-HGYE3UTU.js +416 -0
  34. package/build/chunk-KAYW2W7H.js +1311 -0
  35. package/build/chunk-LFP3UPCM.js +10 -0
  36. package/build/chunk-M44JEFEH.js +62 -0
  37. package/build/chunk-MC5AGLMY.js +39 -0
  38. package/build/chunk-NCZ77J5E.js +19798 -0
  39. package/build/chunk-NVNLDPUJ.js +2122 -0
  40. package/build/chunk-PDCON4KS.js +494 -0
  41. package/build/chunk-Q4OQNY5M.js +25 -0
  42. package/build/chunk-QCUOR2U5.js +75 -0
  43. package/build/chunk-R6RYZZWA.js +46 -0
  44. package/build/chunk-SOJTASZC.js +51 -0
  45. package/build/chunk-SU26PJVX.js +410 -0
  46. package/build/chunk-U5BSBG4R.js +506 -0
  47. package/build/chunk-UEKOZGRO.js +37 -0
  48. package/build/chunk-W6EFUWJ2.js +9 -0
  49. package/build/chunk-WOPREK6Q.js +117 -0
  50. package/build/chunk-WWJ3F4QS.js +127 -0
  51. package/build/chunk-ZGZJEEE3.js +1091 -0
  52. package/build/client/_app/immutable/assets/0.BGa5ZNDM.css +1 -0
  53. package/build/client/_app/immutable/chunks/BUrrLYDt.js +2 -0
  54. package/build/client/_app/immutable/chunks/BgFr_EyZ.js +4 -0
  55. package/build/client/_app/immutable/chunks/Bjy-W4x2.js +82 -0
  56. package/build/client/_app/immutable/chunks/C8qZab0y.js +56 -0
  57. package/build/client/_app/immutable/chunks/C90fLsA5.js +4 -0
  58. package/build/client/_app/immutable/chunks/CRzLMJPS.js +1 -0
  59. package/build/client/_app/immutable/chunks/DLT1iIlz.js +2 -0
  60. package/build/client/_app/immutable/chunks/pIrVVOVY.js +2 -0
  61. package/build/client/_app/immutable/entry/app.0DERehLW.js +3 -0
  62. package/build/client/_app/immutable/entry/payload.DSmR2FwN.js +2 -0
  63. package/build/client/_app/immutable/entry/start.B7srFiqh.js +3 -0
  64. package/build/client/_app/immutable/nodes/0.aD9vJoxR.js +2 -0
  65. package/build/client/_app/immutable/nodes/1.Br4FL91i.js +2 -0
  66. package/build/client/_app/immutable/nodes/2.vl6OYfu3.js +8 -0
  67. package/build/client/_app/immutable/nodes/3.VYqJOLAF.js +2 -0
  68. package/build/client/_app/immutable/nodes/4.jF9UVlFN.js +2 -0
  69. package/build/client/_app/version.json +1 -0
  70. package/build/client/apple-touch-icon.png +0 -0
  71. package/build/client/favicon.ico +0 -0
  72. package/build/client/favicon.svg +48 -0
  73. package/build/client/icon-192.png +0 -0
  74. package/build/client/icon-512.png +0 -0
  75. package/build/client/icon-maskable-512.png +0 -0
  76. package/build/client/icon.svg +44 -0
  77. package/build/client/manifest.webmanifest +15 -0
  78. package/build/error.svelte-JXZDY65F.js +51 -0
  79. package/build/handler.js +716 -0
  80. package/build/hooks.server-34IMHRKC.js +43 -0
  81. package/build/instance-NP3FJIWW.js +2967 -0
  82. package/client/client.mjs +28 -0
  83. package/client/hooks.mjs +7 -0
  84. package/client/register.mjs +5 -0
  85. package/dist/cli.js +62826 -0
  86. package/dist/port.js +36691 -0
  87. package/package.json +20 -1
  88. package/skills/agents/.claude-plugin/plugin.json +5 -0
  89. package/skills/agents/hooks/hooks.json +1 -0
  90. package/skills/agents/hooks/register.ts +57 -0
  91. package/skills/agents/skills/bus-work/SKILL.md +66 -0
  92. package/skills/agents/skills/plan/SKILL.md +98 -0
  93. package/skills/console/.claude-plugin/plugin.json +5 -0
  94. package/skills/console/hooks/hooks.json +1 -0
  95. package/skills/console/hooks/register.ts +10 -0
  96. package/skills/console/skills/bus/SKILL.md +61 -0
  97. package/skills/default/.claude-plugin/plugin.json +5 -0
  98. package/skills/default/skills/git-workflow/SKILL.md +294 -0
  99. package/skills/default/skills/git-workflow/mise-tasks/_lib.sh +107 -0
  100. package/skills/default/skills/git-workflow/mise-tasks/clone +36 -0
  101. package/skills/default/skills/git-workflow/mise-tasks/repo-list +8 -0
  102. package/skills/default/skills/git-workflow/mise-tasks/unpushed +26 -0
  103. package/skills/default/skills/git-workflow/mise-tasks/worktree-add +60 -0
  104. package/skills/default/skills/github/SKILL.md +208 -0
  105. package/skills/default/skills/github/scripts/gh-org +196 -0
  106. package/skills/default/skills/plan-page/SKILL.md +300 -0
  107. package/skills/default/skills/plan-page/assets/index.html +854 -0
  108. package/skills/default/skills/plan-page/scripts/inline +46 -0
  109. package/skills/default/skills/plan-page/scripts/stage +26 -0
  110. package/skills/default/skills/secrets/SKILL.md +134 -0
  111. package/skills/default/skills/secrets/scripts/secrets-check +87 -0
  112. package/skills/default/skills/writing-style/SKILL.md +45 -0
  113. package/skills/extra/.claude-plugin/plugin.json +5 -0
  114. package/skills/extra/skills/artifacts/SKILL.md +52 -0
  115. package/skills/extra/skills/github-actions/SKILL.md +78 -0
  116. package/skills/extra/skills/github-actions/scan +32 -0
  117. package/skills/extra/skills/markdown-review/SKILL.md +70 -0
  118. package/skills/extra/skills/markdown-review/assets/index.html +201 -0
  119. package/skills/extra/skills/markdown-review/scripts/stage +36 -0
  120. package/skills/extra/skills/mise-usage/SKILL.md +155 -0
  121. package/skills/extra/skills/plugin-authoring/SKILL.md +317 -0
  122. package/skills/extra/skills/project-conventions/SKILL.md +64 -0
  123. package/skills/extra/skills/questions/SKILL.md +156 -0
  124. package/skills/extra/skills/questions/assets/index.html +376 -0
  125. package/skills/extra/skills/questions/scripts/stage +26 -0
  126. package/skills/extra/skills/serve/SKILL.md +165 -0
  127. package/skills/extra/skills/serve/scripts/serve +268 -0
  128. package/skills/extra/skills/serve/scripts/server.py +345 -0
  129. package/skills/extra/skills/skill-dependencies/SKILL.md +65 -0
  130. package/skills/extra/skills/skill-maintenance/SKILL.md +101 -0
  131. 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.