mdpush 1.0.0 → 1.1.1

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # mdpush
2
2
 
3
- Push markdown files to [Preview Reader](https://github.com/sielay/preview-reader) from your terminal.
3
+ Push markdown files to [sielay](https://github.com/emiliovos/sielay) from your terminal.
4
4
 
5
5
  ## Install
6
6
 
@@ -35,18 +35,33 @@ Interactive wizard to configure server, token, and default project. Validates yo
35
35
 
36
36
  ### `mdpush push [files...]`
37
37
 
38
- Upload `.md` and `.txt` files to a project.
38
+ Upload `.md`, `.txt`, and `.html` files to a project. HTML files are hosted at `/v/:sid` as sandboxed artifacts with an optional `--type` tag.
39
39
 
40
40
  | Flag | Description |
41
41
  |------|-------------|
42
42
  | `-p, --project <slug>` | Target project (falls back to default from setup) |
43
- | `-f, --folder <path>` | Subfolder within the project |
43
+ | `-f, --folder <path>` | Subfolder within the project (md/txt only — HTML artifacts are flat) |
44
+ | `-t, --type <value>` | HTML artifact type: `spec` \| `review` \| `design` \| `report` \| `playground` (HTML files only) |
44
45
 
45
46
  ```bash
46
- mdpush push README.md # Uses default project
47
- mdpush push -p docs-site *.md # Specific project
48
- mdpush push -p docs-site -f guides plan.md # Into subfolder
49
- mdpush push docs/ # Upload entire directory
47
+ mdpush push README.md # Uses default project
48
+ mdpush push -p docs-site *.md # Specific project
49
+ mdpush push -p docs-site -f guides plan.md # Into subfolder
50
+ mdpush push docs/ # Upload entire directory (md/txt only)
51
+ mdpush push design.html --type design # HTML artifact with type tag
52
+ mdpush push spec.html notes.md --type spec # Mixed batch — type applies only to .html
53
+ ```
54
+
55
+ HTML output includes the viewer URL and type badge:
56
+
57
+ ```
58
+ 🔗 [design] https://docs.example.com/v/abc123 design.html
59
+ ```
60
+
61
+ `.md`/`.txt` output uses the project-relative direct link:
62
+
63
+ ```
64
+ 🔗 https://docs.example.com/p/docs-site/f/xyz789 plan.md
50
65
  ```
51
66
 
52
67
  ### `mdpush login`
@@ -102,12 +117,13 @@ Generate tokens from the web UI: **Sidebar > API Tokens > Generate Token**.
102
117
  | `401 Unauthorized` | Token expired or revoked. Generate a new one from the web UI. |
103
118
  | `403 Forbidden` | No write access to this project. Ask the owner to invite you. |
104
119
  | `404 Not Found` | Wrong project slug. Check it in the web UI URL: `/p/<slug>` |
105
- | `413 Too Large` | File exceeds 5MB limit. |
120
+ | `413 Too Large` | File exceeds 10MB (.md/.txt) or 15MB (.html) limit. |
121
+ | `400 Invalid type` | `--type` value not in whitelist. Use one of: `spec`, `review`, `design`, `report`, `playground`. |
106
122
 
107
123
  ## Requirements
108
124
 
109
125
  - Node.js >= 18
110
- - A running [Preview Reader](https://github.com/sielay/preview-reader) server
126
+ - A running [sielay](https://github.com/emiliovos/sielay) server
111
127
 
112
128
  ## License
113
129
 
package/assets/SKILL.md CHANGED
@@ -1,20 +1,30 @@
1
1
  ---
2
2
  name: mdpush
3
- description: Push markdown files to Preview Reader server. Use when user wants to upload .md/.txt files to their docs portal.
3
+ description: Push markdown, text, and HTML artifact files to Preview Reader / sielay server. Use when user wants to upload .md/.txt files to their docs portal OR when user wants to host/share a self-contained HTML artifact (spec, review, design, report, playground).
4
4
  ---
5
5
 
6
- # mdpush — Push Markdown to Preview Reader
6
+ # mdpush — Push Markdown and HTML Artifacts to Preview Reader
7
7
 
8
- Push `.md` and `.txt` files from the current project to a Preview Reader server.
8
+ Push `.md`, `.txt`, and `.html` files from the current project to a Preview Reader server. HTML files are hosted at `/v/:sid` as sandboxed artifacts with an optional type tag.
9
9
 
10
10
  ## Usage
11
11
 
12
12
  ```
13
- /mdpush <files...> # Push to default project
14
- /mdpush -p <project-slug> <files...> # Push to specific project
15
- /mdpush -f <folder> <files...> # Push to subfolder within project
13
+ /mdpush <files...> # Push to default project
14
+ /mdpush -p <project-slug> <files...> # Push to specific project
15
+ /mdpush -f <folder> <files...> # Push to subfolder within project (md/txt only)
16
+ /mdpush design.html --type design # Upload HTML artifact with type tag
17
+ /mdpush spec.html report.md --type spec # Mix: HTML gets type, .md does not
16
18
  ```
17
19
 
20
+ ### `--type` flag (HTML only)
21
+
22
+ Valid values: `spec | review | design | report | playground`
23
+
24
+ - Only meaningful for `.html` files — silently ignored for `.md`/`.txt`
25
+ - Rejected client-side before any network call if value is outside whitelist (exit 2)
26
+ - Output includes badge when type provided: `OK: design.html [design] → https://docs.sielay.cloud/v/abc123`
27
+
18
28
  ## Setup
19
29
 
20
30
  Requires `~/.mdpush.json` config file:
@@ -39,6 +49,93 @@ Run the push script:
39
49
  bash ~/.claude/skills/mdpush/scripts/push.sh [flags] <files...>
40
50
  ```
41
51
 
42
- Flags: `-p <project>` (override project), `-f <folder>` (subfolder target).
52
+ Flags: `-p <project>` (override project), `-f <folder>` (subfolder, md/txt only), `--type <value>` (HTML artifact taxonomy).
43
53
 
44
54
  Report the script output to the user. If config is missing, show the setup instructions above.
55
+
56
+ ## HTML Quality Recommendations
57
+
58
+ Before generating an HTML artifact for upload, activate these skills to produce polished, non-generic output:
59
+
60
+ | Goal | Skill | When to use |
61
+ |---|---|---|
62
+ | Dashboard, monitoring UI, data-rich layouts | `frontend-design` | Cards, sparklines, status pills, responsive grid |
63
+ | Landing page, admin panel, interactive UI | `ui-ux-pro-max` | 50+ styles, 21 palettes, 50 font pairings |
64
+ | Brand-consistent tokens, style guide, design system | `design` | Ensure visual consistency across artifacts |
65
+
66
+ **Recommended workflow:**
67
+
68
+ ```
69
+ # Polished dashboard artifact
70
+ /frontend-design "Monitoring dashboard with cards, sparklines, status pills"
71
+ # → output: design.html
72
+
73
+ /mdpush design.html --type design
74
+ # → OK: design.html [design] → https://docs.sielay.cloud/v/abc123
75
+ ```
76
+
77
+ ```
78
+ # Interactive playground (sliders, live preview, copy-to-clipboard)
79
+ /ui-ux-pro-max "Easing curve tuner with sliders + animation preview + copy CSS output"
80
+ # → output: playground.html
81
+
82
+ /mdpush playground.html --type playground
83
+ ```
84
+
85
+ Context source: [HTML Effectiveness — Thariq](https://thariqs.github.io/html-effectiveness/) — taxonomy and patterns that inform the `--type` whitelist and quality bar for artifacts.
86
+
87
+ ## Sandbox Limitations
88
+
89
+ HTML artifacts run inside an `<iframe>` with a restrictive sandbox (`sandbox="allow-scripts allow-forms allow-modals"` — **no** `allow-same-origin`). This gives the document a `null` origin. The following APIs are affected:
90
+
91
+ | API | Behavior inside sandbox | Workaround |
92
+ |---|---|---|
93
+ | `localStorage` / `sessionStorage` | Throws `SecurityError` | Use in-memory state (plain JS variables/objects) |
94
+ | `document.cookie` | Returns empty string, writes silently ignored | Use in-memory state |
95
+ | `fetch()` cross-origin | Blocked by CSP — request never leaves browser | Inline all data; no runtime API calls |
96
+ | `fetch()` same-origin | Also blocked (null origin has no same-origin) | Inline all data |
97
+ | Clipboard write (`navigator.clipboard.writeText`) | Works with user gesture | Fine as-is |
98
+ | Form submit | Works (sandbox allows forms) | Fine as-is |
99
+ | DOM events, timers, animations | All work normally | Fine as-is |
100
+ | ES modules (`type="module"`) | Work — null origin does not block modules | Fine as-is |
101
+
102
+ **Artifact-author contract:**
103
+ - Self-contained HTML — all CSS and JS must be inlined or in `<style>`/`<script>` tags
104
+ - No external asset loads (CDN fonts, external images) — inline or omit
105
+ - File size < 15MB enforced server-side (HTTP 413 returned if exceeded)
106
+ - No cookies, no localStorage — design state to live only in memory for the session
107
+
108
+ ## Future: `--inline-all` mode (not yet implemented)
109
+
110
+ Strict self-containment mode for compliance, airgap, or long-term archive scenarios. Pre-processes the HTML client-side **before upload**: compiles detected Tailwind utility classes into a static `<style>` block via Tailwind CLI, fetches Google Fonts / Fontshare CSS plus each `.woff2` and base64-inlines them as `data:font/woff2;base64,...`, and optionally inlines whitelisted CDN scripts. The resulting artifact has zero external network dependencies — survives any future CDN outage, CSP tightening, or sandbox change.
111
+
112
+ ### Trade-offs
113
+
114
+ | Dimension | Current (CSP whitelist, shipped) | `--inline-all` (proposed) |
115
+ |---|---|---|
116
+ | Author overhead | 0s — author writes normal HTML with CDN refs | 3–7s warm preprocess; 30–60s cold (Tailwind CLI install) |
117
+ | Artifact size | 30KB typical | 400–700KB typical (10–30x growth) |
118
+ | CDN dependency | Depends on 9-host whitelist staying available | Zero — fully self-contained |
119
+ | Server bandwidth | Low — artifacts stay small, browser fetches CDN | Higher — every artifact ships fonts/CSS in payload |
120
+ | Implementation effort | 0 (done) | ~1.5d (Tailwind detection + binary inlining + size-cap guard) |
121
+
122
+ ### When to opt in
123
+
124
+ - **Compliance audits** — auditor needs to inspect the exact bytes that render
125
+ - **Airgap deployments** — environment has no internet egress, CDN unreachable
126
+ - **Long-term archive** — artifact must render identically in 5+ years regardless of CDN survival
127
+ - **Untrusted preview environments** — avoid even the metadata leak of CDN requests
128
+
129
+ ### Activation surface (when implemented)
130
+
131
+ Two viable shapes — decide at implementation time:
132
+
133
+ 1. **Strict opt-in flag**: `mdpush artifact.html --inline-all` — author explicit, default stays fast
134
+ 2. **Smart auto-detect**: if HTML contains external `<link>`/`<script>` refs, auto-inline; opt out via `--no-inline`. Pro: no compliance footgun. Con: opaque size growth.
135
+
136
+ ### Why not default now
137
+
138
+ Phase 1 CSP whitelist covers ~95% of artifacts that current Claude/agent workflows generate, with 0s author overhead. The 5–30x size penalty of full inlining is wasted bandwidth for the typical case. Document as roadmap; ship only when compliance or airgap demand surfaces.
139
+
140
+ **Reference**: `plans/260512-1704-html-fidelity-render-as-original/plan.md` — Option D row in the trade-off table that informed this deferral decision.
141
+
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env bash
2
- # mdpush — push .md/.txt files to Preview Reader via API
2
+ # mdpush — push .md/.txt/.html files to Preview Reader via API
3
3
  # Reads config from ~/.mdpush.json (server, token, defaultProject)
4
+ # Returns direct file links for each uploaded file
5
+ # HTML files upload to /v/:sid endpoint; .md/.txt to /p/:project/f/:sid
4
6
  set -euo pipefail
5
7
 
6
8
  CONFIG="$HOME/.mdpush.json"
@@ -33,13 +35,31 @@ fi
33
35
  # Parse CLI args
34
36
  PROJECT="${DEFAULT_PROJECT:-}"
35
37
  FOLDER=""
38
+ TYPE=""
36
39
  FILES=()
37
40
 
38
41
  while [[ $# -gt 0 ]]; do
39
42
  case "$1" in
40
43
  -p) PROJECT="$2"; shift 2 ;;
41
44
  -f) FOLDER="$2"; shift 2 ;;
42
- *) FILES+=("$1"); shift ;;
45
+ --type)
46
+ if [[ $# -lt 2 ]]; then
47
+ echo "ERROR: --type requires a value" >&2
48
+ exit 2
49
+ fi
50
+ TYPE="$2"
51
+ # Strict client-side whitelist — reject before any network call
52
+ case "$TYPE" in
53
+ spec|review|design|report|playground) ;;
54
+ *)
55
+ echo "ERROR: --type must be one of: spec|review|design|report|playground" >&2
56
+ echo " Got: $TYPE" >&2
57
+ exit 2
58
+ ;;
59
+ esac
60
+ shift 2
61
+ ;;
62
+ *) FILES+=("$1"); shift ;;
43
63
  esac
44
64
  done
45
65
 
@@ -50,22 +70,110 @@ fi
50
70
 
51
71
  if [ ${#FILES[@]} -eq 0 ]; then
52
72
  echo "ERROR: No files specified"
53
- echo "Usage: push.sh [-p project] [-f folder] <files...>"
73
+ echo "Usage: push.sh [-p project] [-f folder] [--type spec|review|design|report|playground] <files...>"
54
74
  exit 1
55
75
  fi
56
76
 
57
- # Build query string for folder
58
- QUERY=""
77
+ # Build folder query param (shared across non-HTML uploads)
78
+ FOLDER_QUERY=""
59
79
  if [ -n "$FOLDER" ]; then
60
80
  ENCODED_FOLDER=$(python3 -c "import sys,urllib.parse; print(urllib.parse.quote(sys.argv[1]))" "$FOLDER")
61
- QUERY="?folder=$ENCODED_FOLDER"
81
+ FOLDER_QUERY="?folder=$ENCODED_FOLDER"
62
82
  fi
63
83
 
84
+ # Extract server error message from JSON response body (best-effort)
85
+ extract_server_message() {
86
+ local body="$1"
87
+ local msg=""
88
+ if command -v jq &>/dev/null; then
89
+ msg=$(echo "$body" | jq -r '.message // .error // empty' 2>/dev/null || true)
90
+ else
91
+ msg=$(python3 -c "import sys,json; d=json.loads(sys.argv[1]); print(d.get('message',d.get('error','')))" "$body" 2>/dev/null || true)
92
+ fi
93
+ echo "$msg"
94
+ }
95
+
96
+ # Millisecond-precision timestamp — bash 5+ date supports %3N;
97
+ # macOS with BSD date does NOT support %3N: fall back to python3.
98
+ _now_ms() {
99
+ local ms
100
+ ms=$(date +%s%3N 2>/dev/null)
101
+ # Detect failed %3N expansion: BSD date returns literal "%3N" suffix
102
+ if [[ "$ms" == *%3N* ]]; then
103
+ ms=$(python3 -c "import time; print(int(time.time()*1000))")
104
+ fi
105
+ echo "$ms"
106
+ }
107
+
108
+ # JSONL metrics log — created chmod 600 on first write
109
+ METRICS_FILE="$HOME/.mdpush-metrics.jsonl"
110
+
111
+ # Append one validated JSONL line to metrics file
112
+ # Args: ts file ext bytes ms_total ms_network ms_local sid url type status
113
+ _append_metric() {
114
+ local ts="$1" file="$2" ext="$3" bytes="$4"
115
+ local ms_total="$5" ms_network="$6" ms_local="$7"
116
+ local sid="$8" url="$9" type="${10}" status="${11}"
117
+
118
+ # Escape double-quotes in strings (basic safety; filenames rarely contain them)
119
+ file="${file//\"/\\\"}"
120
+ url="${url//\"/\\\"}"
121
+
122
+ # Build JSON with null for empty optional fields
123
+ local type_json="null"
124
+ [[ -n "$type" ]] && type_json="\"$type\""
125
+
126
+ local url_json="null"
127
+ [[ -n "$url" ]] && url_json="\"$url\""
128
+
129
+ local sid_json="null"
130
+ [[ -n "$sid" ]] && sid_json="\"$sid\""
131
+
132
+ local line
133
+ line=$(printf '{"ts":"%s","file":"%s","ext":"%s","bytes":%s,"ms_total":%s,"ms_network":%s,"ms_local":%s,"sid":%s,"url":%s,"type":%s,"status":"%s"}' \
134
+ "$ts" "$file" "$ext" "$bytes" \
135
+ "$ms_total" "$ms_network" "$ms_local" \
136
+ "$sid_json" "$url_json" "$type_json" "$status")
137
+
138
+ # Validate JSON before writing (jq -c '.' re-emits compacted valid JSON or fails)
139
+ if command -v jq &>/dev/null; then
140
+ local validated
141
+ validated=$(jq -c '.' <<< "$line" 2>/dev/null) || {
142
+ echo "METRICS: invalid JSON line — skipping write (line: $line)" >&2
143
+ return
144
+ }
145
+ line="$validated"
146
+ fi
147
+
148
+ # Create file with restricted permissions on first write
149
+ if [ ! -f "$METRICS_FILE" ]; then
150
+ install -m 600 /dev/null "$METRICS_FILE"
151
+ fi
152
+ printf '%s\n' "$line" >> "$METRICS_FILE"
153
+ }
154
+
155
+ # Optional Discord webhook notification (fire-and-forget)
156
+ _discord_notify() {
157
+ local file="$1" ms_total="$2" url="$3"
158
+ if [[ -n "${MDPUSH_METRICS_DISCORD_WEBHOOK:-}" ]]; then
159
+ local payload
160
+ payload=$(printf '{"content":"📤 %s → %dms → %s"}' "$file" "$ms_total" "$url")
161
+ curl -s -X POST "$MDPUSH_METRICS_DISCORD_WEBHOOK" \
162
+ -H "Content-Type: application/json" \
163
+ -A "mdpush-metrics/1.0 (preview-reader-pipeline)" \
164
+ -d "$payload" \
165
+ --max-time 3 &
166
+ fi
167
+ }
168
+
64
169
  # Upload each file
65
170
  SUCCESS=0
66
171
  FAILED=0
67
172
 
68
173
  for FILE in "${FILES[@]}"; do
174
+ # Per-file t_start reset so each JSONL row reflects one file's pipeline, not the batch
175
+ t_start=$(_now_ms)
176
+
69
177
  # Check file exists
70
178
  if [ ! -f "$FILE" ]; then
71
179
  echo "SKIP: $FILE (not found)"
@@ -73,26 +181,223 @@ for FILE in "${FILES[@]}"; do
73
181
  fi
74
182
 
75
183
  BASENAME=$(basename "$FILE")
184
+ EXT="${BASENAME##*.}"
76
185
 
77
- # Filter to .md/.txt only
78
- if [[ ! "$BASENAME" =~ \.(md|txt)$ ]]; then
79
- echo "SKIP: $BASENAME (not .md or .txt)"
80
- continue
81
- fi
186
+ # Extension whitelist: md, txt, html
187
+ case "$EXT" in
188
+ md|txt|html) ;;
189
+ *)
190
+ echo "SKIP: $BASENAME (unsupported extension — use .md, .txt, or .html)"
191
+ continue
192
+ ;;
193
+ esac
194
+
195
+ # Portable file size: wc -c avoids GNU/BSD stat flag differences
196
+ BYTES=$(wc -c < "$FILE" | tr -d ' ')
197
+
198
+ # --- HTML path: upload to /v/:sid endpoint ---
199
+ if [ "$EXT" = "html" ]; then
200
+ # Build query: always ?contentType=html; add &type=<value> if --type was given
201
+ HTML_QUERY="?contentType=html"
202
+ if [ -n "$TYPE" ]; then
203
+ HTML_QUERY="${HTML_QUERY}&type=${TYPE}"
204
+ fi
205
+
206
+ t_pre_curl=$(_now_ms)
207
+ RESPONSE=$(curl -s -w "\n%{http_code}" \
208
+ --max-time 60 \
209
+ -X POST \
210
+ -H "Authorization: Bearer $TOKEN" \
211
+ -H "X-Requested-With: XMLHttpRequest" \
212
+ -F "files=@$FILE" \
213
+ "${SERVER}/api/projects/${PROJECT}/upload${HTML_QUERY}") || {
214
+ t_post_curl=$(_now_ms)
215
+ t_end=$(_now_ms)
216
+ TS=$(date -u +%Y-%m-%dT%H:%M:%S.000Z 2>/dev/null || date -u +%Y-%m-%dT%H:%M:%SZ)
217
+ ms_network=$((t_post_curl - t_pre_curl))
218
+ ms_local=$((t_pre_curl - t_start))
219
+ ms_total=$((t_end - t_start))
220
+ _append_metric "$TS" "$BASENAME" "$EXT" "$BYTES" \
221
+ "$ms_total" "$ms_network" "$ms_local" "" "" "${TYPE}" "error_network"
222
+ echo "FAIL: $BASENAME — network error (curl failed)"
223
+ FAILED=$((FAILED + 1))
224
+ continue
225
+ }
226
+ t_post_curl=$(_now_ms)
227
+
228
+ HTTP_CODE=$(echo "$RESPONSE" | tail -1)
229
+ BODY=$(echo "$RESPONSE" | sed '$d')
230
+
231
+ if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then
232
+ # Server response includes absolute 'url' field — read it directly
233
+ URL=""
234
+ SID=""
235
+ if command -v jq &>/dev/null; then
236
+ URL=$(echo "$BODY" | jq -r '.url // .uploaded[0].url // empty' 2>/dev/null || true)
237
+ SID=$(echo "$BODY" | jq -r '.uploaded[0].sid // .sid // empty' 2>/dev/null || true)
238
+ else
239
+ URL=$(python3 -c "import sys,json; d=json.loads(sys.argv[1]); print(d.get('url',d.get('uploaded',[{}])[0].get('url','')))" "$BODY" 2>/dev/null || true)
240
+ SID=$(python3 -c "import sys,json; d=json.loads(sys.argv[1]); print(d.get('sid',d.get('uploaded',[{}])[0].get('sid','')))" "$BODY" 2>/dev/null || true)
241
+ fi
82
242
 
83
- HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
84
- --max-time 60 \
85
- -X POST \
86
- -H "Authorization: Bearer $TOKEN" \
87
- -F "files=@$FILE" \
88
- "${SERVER}/api/projects/${PROJECT}/upload${QUERY}")
243
+ BADGE=""
244
+ if [ -n "$TYPE" ]; then
245
+ BADGE=" [$TYPE]"
246
+ fi
89
247
 
90
- if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then
91
- echo "OK: $BASENAME ($HTTP_CODE)"
92
- ((SUCCESS++))
248
+ t_end=$(_now_ms)
249
+ TS=$(date -u +%Y-%m-%dT%H:%M:%S.000Z 2>/dev/null || date -u +%Y-%m-%dT%H:%M:%SZ)
250
+ ms_network=$((t_post_curl - t_pre_curl))
251
+ ms_local=$((t_pre_curl - t_start))
252
+ ms_total=$((t_end - t_start))
253
+
254
+ if [ -n "$URL" ]; then
255
+ echo "OK: $BASENAME${BADGE} → $URL"
256
+ _append_metric "$TS" "$BASENAME" "$EXT" "$BYTES" \
257
+ "$ms_total" "$ms_network" "$ms_local" "$SID" "$URL" "${TYPE}" "ok"
258
+ _discord_notify "$BASENAME" "$ms_total" "$URL"
259
+ else
260
+ echo "OK: $BASENAME${BADGE} (HTTP $HTTP_CODE — no URL in response)"
261
+ _append_metric "$TS" "$BASENAME" "$EXT" "$BYTES" \
262
+ "$ms_total" "$ms_network" "$ms_local" "$SID" "" "${TYPE}" "ok"
263
+ fi
264
+ SUCCESS=$((SUCCESS + 1))
265
+ else
266
+ t_end=$(_now_ms)
267
+ TS=$(date -u +%Y-%m-%dT%H:%M:%S.000Z 2>/dev/null || date -u +%Y-%m-%dT%H:%M:%SZ)
268
+ ms_network=$((t_post_curl - t_pre_curl))
269
+ ms_local=$((t_pre_curl - t_start))
270
+ ms_total=$((t_end - t_start))
271
+
272
+ SERVER_MSG=$(extract_server_message "$BODY")
273
+ METRIC_STATUS="error_other"
274
+ case "$HTTP_CODE" in
275
+ 413)
276
+ echo "FAIL: $BASENAME — file too large (server limit is 15MB)"
277
+ [ -n "$SERVER_MSG" ] && echo " Server: $SERVER_MSG"
278
+ METRIC_STATUS="error_413"
279
+ ;;
280
+ 415)
281
+ echo "FAIL: $BASENAME — unsupported media type (server rejected MIME)"
282
+ [ -n "$SERVER_MSG" ] && echo " Server: $SERVER_MSG"
283
+ METRIC_STATUS="error_415"
284
+ ;;
285
+ 400)
286
+ echo "FAIL: $BASENAME — bad request (check --type value or file content)"
287
+ [ -n "$SERVER_MSG" ] && echo " Server: $SERVER_MSG"
288
+ METRIC_STATUS="error_400"
289
+ ;;
290
+ 401|403)
291
+ echo "FAIL: $BASENAME — authentication error (check token in $CONFIG)"
292
+ ;;
293
+ *)
294
+ echo "FAIL: $BASENAME (HTTP $HTTP_CODE)"
295
+ [ -n "$SERVER_MSG" ] && echo " Server: $SERVER_MSG"
296
+ ;;
297
+ esac
298
+ _append_metric "$TS" "$BASENAME" "$EXT" "$BYTES" \
299
+ "$ms_total" "$ms_network" "$ms_local" "" "" "${TYPE}" "$METRIC_STATUS"
300
+ FAILED=$((FAILED + 1))
301
+ fi
302
+
303
+ # --- Markdown / text path: original behavior ---
93
304
  else
94
- echo "FAIL: $BASENAME (HTTP $HTTP_CODE)"
95
- ((FAILED++))
305
+ t_pre_curl=$(_now_ms)
306
+ RESPONSE=$(curl -s -w "\n%{http_code}" \
307
+ --max-time 60 \
308
+ -X POST \
309
+ -H "Authorization: Bearer $TOKEN" \
310
+ -H "X-Requested-With: XMLHttpRequest" \
311
+ -F "files=@$FILE" \
312
+ "${SERVER}/api/projects/${PROJECT}/upload${FOLDER_QUERY}") || {
313
+ t_post_curl=$(_now_ms)
314
+ t_end=$(_now_ms)
315
+ TS=$(date -u +%Y-%m-%dT%H:%M:%S.000Z 2>/dev/null || date -u +%Y-%m-%dT%H:%M:%SZ)
316
+ ms_network=$((t_post_curl - t_pre_curl))
317
+ ms_local=$((t_pre_curl - t_start))
318
+ ms_total=$((t_end - t_start))
319
+ _append_metric "$TS" "$BASENAME" "$EXT" "$BYTES" \
320
+ "$ms_total" "$ms_network" "$ms_local" "" "" "" "error_network"
321
+ echo "FAIL: $BASENAME — network error (curl failed)"
322
+ FAILED=$((FAILED + 1))
323
+ continue
324
+ }
325
+ t_post_curl=$(_now_ms)
326
+
327
+ HTTP_CODE=$(echo "$RESPONSE" | tail -1)
328
+ BODY=$(echo "$RESPONSE" | sed '$d')
329
+
330
+ if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then
331
+ # Try to read absolute url from response first; fall back to SID-based link
332
+ URL=""
333
+ SID=""
334
+ if command -v jq &>/dev/null; then
335
+ URL=$(echo "$BODY" | jq -r '.url // .uploaded[0].url // empty' 2>/dev/null || true)
336
+ SID=$(echo "$BODY" | jq -r '.uploaded[0].sid // empty' 2>/dev/null || true)
337
+ else
338
+ URL=$(python3 -c "import sys,json; d=json.loads(sys.argv[1]); print(d.get('url',d.get('uploaded',[{}])[0].get('url','')))" "$BODY" 2>/dev/null || true)
339
+ SID=$(python3 -c "import sys,json; d=json.loads(sys.argv[1]); print(d.get('uploaded',[])[0].get('sid',''))" "$BODY" 2>/dev/null || true)
340
+ fi
341
+
342
+ t_end=$(_now_ms)
343
+ TS=$(date -u +%Y-%m-%dT%H:%M:%S.000Z 2>/dev/null || date -u +%Y-%m-%dT%H:%M:%SZ)
344
+ ms_network=$((t_post_curl - t_pre_curl))
345
+ ms_local=$((t_pre_curl - t_start))
346
+ ms_total=$((t_end - t_start))
347
+
348
+ if [ -n "$URL" ]; then
349
+ echo "OK: $BASENAME → $URL"
350
+ _append_metric "$TS" "$BASENAME" "$EXT" "$BYTES" \
351
+ "$ms_total" "$ms_network" "$ms_local" "$SID" "$URL" "" "ok"
352
+ _discord_notify "$BASENAME" "$ms_total" "$URL"
353
+ elif [ -n "$SID" ]; then
354
+ echo "OK: $BASENAME → ${SERVER}/p/${PROJECT}/f/${SID}"
355
+ _append_metric "$TS" "$BASENAME" "$EXT" "$BYTES" \
356
+ "$ms_total" "$ms_network" "$ms_local" "$SID" "${SERVER}/p/${PROJECT}/f/${SID}" "" "ok"
357
+ _discord_notify "$BASENAME" "$ms_total" "${SERVER}/p/${PROJECT}/f/${SID}"
358
+ else
359
+ echo "OK: $BASENAME (HTTP $HTTP_CODE)"
360
+ _append_metric "$TS" "$BASENAME" "$EXT" "$BYTES" \
361
+ "$ms_total" "$ms_network" "$ms_local" "" "" "" "ok"
362
+ fi
363
+ SUCCESS=$((SUCCESS + 1))
364
+ else
365
+ t_end=$(_now_ms)
366
+ TS=$(date -u +%Y-%m-%dT%H:%M:%S.000Z 2>/dev/null || date -u +%Y-%m-%dT%H:%M:%SZ)
367
+ ms_network=$((t_post_curl - t_pre_curl))
368
+ ms_local=$((t_pre_curl - t_start))
369
+ ms_total=$((t_end - t_start))
370
+
371
+ SERVER_MSG=$(extract_server_message "$BODY")
372
+ METRIC_STATUS="error_other"
373
+ case "$HTTP_CODE" in
374
+ 413)
375
+ echo "FAIL: $BASENAME — file too large (server limit is 15MB)"
376
+ [ -n "$SERVER_MSG" ] && echo " Server: $SERVER_MSG"
377
+ METRIC_STATUS="error_413"
378
+ ;;
379
+ 415)
380
+ echo "FAIL: $BASENAME — unsupported media type"
381
+ [ -n "$SERVER_MSG" ] && echo " Server: $SERVER_MSG"
382
+ METRIC_STATUS="error_415"
383
+ ;;
384
+ 400)
385
+ echo "FAIL: $BASENAME — bad request"
386
+ [ -n "$SERVER_MSG" ] && echo " Server: $SERVER_MSG"
387
+ METRIC_STATUS="error_400"
388
+ ;;
389
+ 401|403)
390
+ echo "FAIL: $BASENAME — authentication error (check token in $CONFIG)"
391
+ ;;
392
+ *)
393
+ echo "FAIL: $BASENAME (HTTP $HTTP_CODE)"
394
+ [ -n "$SERVER_MSG" ] && echo " Server: $SERVER_MSG"
395
+ ;;
396
+ esac
397
+ _append_metric "$TS" "$BASENAME" "$EXT" "$BYTES" \
398
+ "$ms_total" "$ms_network" "$ms_local" "" "" "" "$METRIC_STATUS"
399
+ FAILED=$((FAILED + 1))
400
+ fi
96
401
  fi
97
402
  done
98
403
 
package/package.json CHANGED
@@ -1,10 +1,9 @@
1
1
  {
2
2
  "name": "mdpush",
3
- "version": "1.0.0",
4
- "description": "Push markdown files to Preview Reader from your terminal",
3
+ "version": "1.1.1",
5
4
  "type": "module",
6
5
  "engines": {
7
- "node": ">=18.0.0"
6
+ "node": ">=20.0.0"
8
7
  },
9
8
  "bin": {
10
9
  "mdpush": "./bin/mdpush.js"
@@ -13,26 +12,12 @@
13
12
  "bin/",
14
13
  "src/",
15
14
  "assets/",
15
+ "README.md",
16
16
  "LICENSE"
17
17
  ],
18
- "keywords": [
19
- "markdown",
20
- "preview",
21
- "reader",
22
- "docs",
23
- "push",
24
- "cli",
25
- "upload"
26
- ],
27
- "repository": {
28
- "type": "git",
29
- "url": "https://github.com/sielay/preview-reader",
30
- "directory": "packages/cli"
18
+ "scripts": {
19
+ "prepublishOnly": "bash ./scripts/sync-skill-assets.sh"
31
20
  },
32
- "homepage": "https://github.com/sielay/preview-reader/tree/master/packages/cli",
33
- "bugs": "https://github.com/sielay/preview-reader/issues",
34
- "license": "MIT",
35
- "author": "sielay",
36
21
  "dependencies": {
37
22
  "commander": "^12.0.0",
38
23
  "chalk": "^5.3.0",
@@ -5,7 +5,7 @@ import { readConfig } from '../lib/config-store.js'
5
5
  export function configCommand() {
6
6
  const config = readConfig()
7
7
  if (!config.server) {
8
- console.log(chalk.yellow('Not configured. Run: mdpush setup'))
8
+ console.log(chalk.yellow('Not logged in. Run: mdpush login --server <url>'))
9
9
  return
10
10
  }
11
11
 
@@ -13,7 +13,4 @@ export function configCommand() {
13
13
  console.log(` Server: ${config.server}`)
14
14
  console.log(` Username: ${config.username}`)
15
15
  console.log(` Token: ${config.token ? '[stored]' : 'none'}`)
16
- if (config.defaultProject) {
17
- console.log(` Project: ${config.defaultProject}`)
18
- }
19
16
  }
@@ -2,7 +2,7 @@ import chalk from 'chalk'
2
2
  import prompts from 'prompts'
3
3
  import { writeConfig } from '../lib/config-store.js'
4
4
 
5
- /** Login to Preview Reader server — prompts credentials, stores API token */
5
+ /** Login to sielay server — prompts credentials, stores API token */
6
6
  export async function loginCommand(options) {
7
7
  const server = options.server.replace(/\/$/, '')
8
8
 
@@ -29,14 +29,19 @@ export async function loginCommand(options) {
29
29
  throw new Error(err.error || `Login failed (${loginRes.status})`)
30
30
  }
31
31
 
32
- const { token: jwt } = await loginRes.json()
32
+ // The login body intentionally carries no JWT (audit LOW — httpOnly cookie
33
+ // only), so relay the session cookie to mint the revocable API token.
34
+ const jwtCookie = (loginRes.headers.getSetCookie?.() || [])
35
+ .map(c => c.split(';')[0])
36
+ .find(c => c.startsWith('jwt='))
37
+ if (!jwtCookie) throw new Error('Login succeeded but no session cookie received')
33
38
 
34
39
  // Step 2: Generate API token for CLI
35
40
  const tokenRes = await fetch(`${server}/api/auth/tokens`, {
36
41
  method: 'POST',
37
42
  headers: {
38
43
  'Content-Type': 'application/json',
39
- 'Authorization': `Bearer ${jwt}`
44
+ 'Cookie': jwtCookie
40
45
  },
41
46
  body: JSON.stringify({ name: 'mdpush-cli' })
42
47
  })
@@ -5,18 +5,22 @@ import ora from 'ora'
5
5
  import { apiRequest } from '../lib/api-request.js'
6
6
  import { readConfig } from '../lib/config-store.js'
7
7
 
8
- /** Push markdown files to a project */
8
+ const HTML_TYPE_WHITELIST = new Set(['spec', 'review', 'design', 'report', 'playground'])
9
+
10
+ const isHtml = (f) => /\.html$/i.test(f)
11
+ const isPdf = (f) => /\.pdf$/i.test(f)
12
+ const isMdTxt = (f) => /\.(md|txt)$/i.test(f)
13
+
14
+ /** Push markdown, text, and HTML artifact files to a project */
9
15
  export async function pushCommand(files, options) {
10
- const config = readConfig()
11
- const project = options.project || config.defaultProject
12
- const { folder } = options
16
+ const { project, folder, type } = options
13
17
 
14
- if (!project) {
15
- console.log(chalk.red('No project specified. Use -p <slug> or run: mdpush setup'))
16
- process.exit(1)
18
+ if (type && !HTML_TYPE_WHITELIST.has(type)) {
19
+ console.log(chalk.red(`Invalid --type "${type}". Allowed: ${[...HTML_TYPE_WHITELIST].join('|')}`))
20
+ process.exit(2)
17
21
  }
18
22
 
19
- // Resolve file list — expand directories
23
+ // Resolve file list — expand directories (md/txt only inside dirs)
20
24
  const resolvedFiles = []
21
25
  for (const f of files) {
22
26
  const stat = fs.statSync(f, { throwIfNoEntry: false })
@@ -25,44 +29,92 @@ export async function pushCommand(files, options) {
25
29
  continue
26
30
  }
27
31
  if (stat.isDirectory()) {
32
+ // Directory expansion stays md/txt-only — don't sweep up large binaries
33
+ // (HTML/PDF) implicitly; pass those as explicit file args.
28
34
  const dirFiles = fs.readdirSync(f, { recursive: true })
29
- .filter(name => /\.(md|txt)$/i.test(name))
35
+ .filter(name => isMdTxt(name))
30
36
  .map(name => path.join(f, name))
31
37
  resolvedFiles.push(...dirFiles)
32
- } else if (/\.(md|txt)$/i.test(f)) {
38
+ } else if (isMdTxt(f) || isHtml(f) || isPdf(f)) {
33
39
  resolvedFiles.push(f)
34
40
  } else {
35
- console.log(chalk.yellow(`Skipping: ${f} (not .md or .txt)`))
41
+ console.log(chalk.yellow(`Skipping: ${f} (not .md, .txt, .html, or .pdf)`))
36
42
  }
37
43
  }
38
44
 
39
45
  if (resolvedFiles.length === 0) {
40
- console.log(chalk.red('No .md or .txt files to upload.'))
46
+ console.log(chalk.red('No .md, .txt, .html, or .pdf files to upload.'))
41
47
  process.exit(1)
42
48
  }
43
49
 
50
+ const htmlFiles = resolvedFiles.filter(isHtml)
51
+ const otherFiles = resolvedFiles.filter(f => !isHtml(f))
52
+
53
+ if (htmlFiles.length > 0 && folder) {
54
+ console.log(chalk.yellow(`Note: --folder is ignored for HTML uploads (artifacts are flat by design)`))
55
+ }
56
+
44
57
  console.log(chalk.blue(`Uploading ${resolvedFiles.length} file(s) to ${project}${folder ? `/${folder}` : ''}...`))
45
58
 
59
+ const uploadedFiles = []
46
60
  let success = 0
47
61
  let failed = 0
48
62
 
63
+ // Per-file dispatch — mirrors push.sh behavior, simpler than batched FormData
49
64
  for (const filePath of resolvedFiles) {
50
- const spinner = ora(`Uploading ${path.basename(filePath)}`).start()
65
+ const basename = path.basename(filePath)
66
+ const spinner = ora(`Uploading ${basename}`).start()
51
67
  try {
52
68
  const formData = new FormData()
53
69
  const content = fs.readFileSync(filePath)
54
- formData.append('files', new Blob([content]), path.basename(filePath))
70
+ formData.append('files', new Blob([content]), basename)
55
71
 
56
- const url = `/api/projects/${project}/upload${folder ? `?folder=${encodeURIComponent(folder)}` : ''}`
57
- await apiRequest(url, { method: 'POST', headers: {}, body: formData })
72
+ const html = isHtml(filePath)
73
+ const pdf = isPdf(filePath)
74
+ const params = new URLSearchParams()
75
+ if (html) {
76
+ params.set('contentType', 'html')
77
+ if (type) params.set('type', type)
78
+ } else if (pdf) {
79
+ params.set('contentType', 'pdf')
80
+ if (folder) params.set('folder', folder)
81
+ } else if (folder) {
82
+ params.set('folder', folder)
83
+ }
84
+ const query = params.toString() ? `?${params.toString()}` : ''
85
+ const url = `/api/projects/${project}/upload${query}`
58
86
 
59
- spinner.succeed(chalk.green(path.basename(filePath)))
87
+ const res = await apiRequest(url, {
88
+ method: 'POST',
89
+ headers: { 'X-Requested-With': 'XMLHttpRequest' },
90
+ body: formData
91
+ })
92
+ const data = await res.json()
93
+
94
+ spinner.succeed(chalk.green(basename))
60
95
  success++
96
+
97
+ if (data.uploaded) uploadedFiles.push(...data.uploaded)
61
98
  } catch (err) {
62
- spinner.fail(chalk.red(`${path.basename(filePath)}: ${err.message}`))
99
+ spinner.fail(chalk.red(`${basename}: ${err.message}`))
63
100
  failed++
64
101
  }
65
102
  }
66
103
 
67
104
  console.log(`\n${chalk.green(`${success} uploaded`)}${failed ? `, ${chalk.red(`${failed} failed`)}` : ''}`)
105
+
106
+ if (uploadedFiles.length > 0) {
107
+ const config = readConfig()
108
+ for (const f of uploadedFiles) {
109
+ if (!f.sid) continue
110
+ if (f.url) {
111
+ // HTML artifact: server emits viewer URL
112
+ const badge = f.type ? chalk.magenta(`[${f.type}] `) : ''
113
+ console.log(chalk.cyan(`-> ${badge}${f.url} ${f.name}`))
114
+ } else {
115
+ // md/txt: legacy /p/<slug>/f/<sid> path
116
+ console.log(chalk.cyan(`-> ${config.server}/p/${project}/f/${f.sid} ${f.name}`))
117
+ }
118
+ }
119
+ }
68
120
  }
@@ -0,0 +1,124 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import os from 'os'
4
+ import chalk from 'chalk'
5
+ import ora from 'ora'
6
+ import { apiRequest } from '../lib/api-request.js'
7
+
8
+ /**
9
+ * `mdpush skill push <dir>` / `mdpush skill install <slug>` — publish a Claude
10
+ * Code skill bundle to sielay (project kind='skill') and pull one back down
11
+ * into ~/.claude/skills/. Server counterpart: GET /api/skills(+/:slug/manifest).
12
+ */
13
+
14
+ // Mirrors the server's TEXT_UPLOAD_EXTENSIONS (upload-middleware.js)
15
+ const SKILL_FILE_EXTENSIONS = new Set(['.md', '.txt', '.py', '.sh', '.js', '.mjs', '.cjs', '.ps1', '.json', '.yaml', '.yml'])
16
+
17
+ /** Recursive walk: relative POSIX paths, dotfiles + node_modules skipped. */
18
+ function walkSkillDir(root, rel = '') {
19
+ const out = []
20
+ for (const e of fs.readdirSync(path.join(root, rel), { withFileTypes: true })) {
21
+ if (e.name.startsWith('.') || e.name === 'node_modules') continue
22
+ const relPath = rel ? `${rel}/${e.name}` : e.name
23
+ if (e.isDirectory()) out.push(...walkSkillDir(root, relPath))
24
+ else if (e.isFile() && SKILL_FILE_EXTENSIONS.has(path.extname(e.name).toLowerCase())) out.push(relPath)
25
+ }
26
+ return out
27
+ }
28
+
29
+ /** Push a local skill directory to the server as a kind='skill' project. */
30
+ export async function skillPushCommand(dir, options) {
31
+ const root = path.resolve(dir)
32
+ if (!fs.existsSync(path.join(root, 'SKILL.md'))) {
33
+ console.log(chalk.red(`No SKILL.md in ${root} — not a skill directory.`))
34
+ process.exit(2)
35
+ }
36
+
37
+ const slug = options.project || path.basename(root).toLowerCase().replace(/[^a-z0-9-]+/g, '-')
38
+ const files = walkSkillDir(root)
39
+ if (!files.includes('SKILL.md')) files.unshift('SKILL.md')
40
+
41
+ // Ensure the skill project exists (idempotent: reuse when slug matches)
42
+ const listRes = await apiRequest('/api/projects')
43
+ const projects = await listRes.json()
44
+ let project = projects.find(p => p.slug === slug)
45
+ if (project && project.kind !== 'skill') {
46
+ console.log(chalk.red(`Project "${slug}" exists but is kind='${project.kind}', not a skill. Aborting.`))
47
+ process.exit(2)
48
+ }
49
+ if (!project) {
50
+ const createRes = await apiRequest('/api/projects', {
51
+ method: 'POST',
52
+ headers: { 'Content-Type': 'application/json' },
53
+ body: JSON.stringify({ name: slug, kind: 'skill' })
54
+ })
55
+ project = await createRes.json()
56
+ console.log(chalk.blue(`Created skill project "${project.slug}"`))
57
+ }
58
+
59
+ console.log(chalk.blue(`Pushing ${files.length} file(s) to skill "${project.slug}"...`))
60
+ let success = 0, failed = 0
61
+ for (const relPath of files) {
62
+ const spinner = ora(relPath).start()
63
+ try {
64
+ const formData = new FormData()
65
+ formData.append('files', new Blob([fs.readFileSync(path.join(root, relPath))]), path.basename(relPath))
66
+ const folder = path.dirname(relPath)
67
+ const query = folder && folder !== '.' ? `?folder=${encodeURIComponent(folder)}` : ''
68
+ await apiRequest(`/api/projects/${project.slug}/upload${query}`, {
69
+ method: 'POST',
70
+ headers: { 'X-Requested-With': 'XMLHttpRequest' },
71
+ body: formData
72
+ })
73
+ spinner.succeed(chalk.green(relPath))
74
+ success++
75
+ } catch (err) {
76
+ spinner.fail(chalk.red(`${relPath}: ${err.message}`))
77
+ failed++
78
+ }
79
+ }
80
+ console.log(`\n${chalk.green(`${success} pushed`)}${failed ? `, ${chalk.red(`${failed} failed`)}` : ''}`)
81
+ if (failed) process.exit(1)
82
+ }
83
+
84
+ /** Install a published skill into ~/.claude/skills/<slug> (or --dest). */
85
+ export async function skillInstallCommand(slug, options) {
86
+ const manifestRes = await apiRequest(`/api/skills/${slug}/manifest`)
87
+ const manifest = await manifestRes.json()
88
+
89
+ const dest = path.resolve(options.dest || path.join(os.homedir(), '.claude', 'skills', slug))
90
+ console.log(chalk.blue(`Installing "${manifest.name || slug}" → ${dest} (${manifest.files.length} files)`))
91
+
92
+ let success = 0, failed = 0
93
+ for (const f of manifest.files) {
94
+ const spinner = ora(f.path).start()
95
+ try {
96
+ // Guard: manifest paths are server-generated, but never trust a path
97
+ // that would escape the destination directory.
98
+ const target = path.join(dest, f.path)
99
+ if (!target.startsWith(dest + path.sep)) throw new Error('Unsafe path in manifest')
100
+ const encoded = f.path.split('/').map(encodeURIComponent).join('/')
101
+ const res = await apiRequest(`/api/projects/${slug}/download/${encoded}`)
102
+ fs.mkdirSync(path.dirname(target), { recursive: true })
103
+ fs.writeFileSync(target, Buffer.from(await res.arrayBuffer()))
104
+ if (target.endsWith('.sh')) { try { fs.chmodSync(target, 0o755) } catch {} }
105
+ spinner.succeed(chalk.green(f.path))
106
+ success++
107
+ } catch (err) {
108
+ spinner.fail(chalk.red(`${f.path}: ${err.message}`))
109
+ failed++
110
+ }
111
+ }
112
+ console.log(`\n${chalk.green(`${success} installed`)}${failed ? `, ${chalk.red(`${failed} failed`)}` : ''}`)
113
+ if (failed) process.exit(1)
114
+ }
115
+
116
+ /** List published skills. */
117
+ export async function skillListCommand() {
118
+ const res = await apiRequest('/api/skills')
119
+ const skills = await res.json()
120
+ if (skills.length === 0) return console.log(chalk.yellow('No skills published.'))
121
+ for (const s of skills) {
122
+ console.log(`${chalk.cyan(s.slug.padEnd(24))} ${s.description || chalk.gray('(no description)')}`)
123
+ }
124
+ }
package/src/index.js CHANGED
@@ -3,16 +3,12 @@ import { loginCommand } from './commands/login-command.js'
3
3
  import { pushCommand } from './commands/push-command.js'
4
4
  import { configCommand } from './commands/config-command.js'
5
5
  import { logoutCommand } from './commands/logout-command.js'
6
- import { setupCommand } from './commands/setup-command.js'
6
+ import { skillPushCommand, skillInstallCommand, skillListCommand } from './commands/skill-command.js'
7
7
 
8
8
  program
9
9
  .name('mdpush')
10
- .description('Push markdown files to Preview Reader')
11
- .version('1.0.0')
12
-
13
- program.command('setup')
14
- .description('Interactive setup — configure server, token, and default project')
15
- .action(setupCommand)
10
+ .description('Push markdown, text, and HTML artifact files to sielay')
11
+ .version('1.1.0')
16
12
 
17
13
  program.command('login')
18
14
  .description('Authenticate with server')
@@ -20,12 +16,32 @@ program.command('login')
20
16
  .action(loginCommand)
21
17
 
22
18
  program.command('push')
23
- .description('Upload files to a project')
24
- .option('-p, --project <slug>', 'Project slug')
25
- .option('-f, --folder <path>', 'Target folder within project')
19
+ .description('Upload .md/.txt/.html files to a project')
20
+ .requiredOption('-p, --project <slug>', 'Project slug')
21
+ .option('-f, --folder <path>', 'Target folder within project (md/txt only)')
22
+ .option('-t, --type <value>', 'HTML artifact type: spec|review|design|report|playground (HTML files only)')
26
23
  .argument('<files...>', 'Files or directory to upload')
27
24
  .action(pushCommand)
28
25
 
26
+ const skill = program.command('skill')
27
+ .description('Publish and install Claude Code skill bundles (projects with kind=skill)')
28
+
29
+ skill.command('push')
30
+ .description('Push a skill directory (must contain SKILL.md) to the server')
31
+ .argument('<dir>', 'Skill directory')
32
+ .option('-p, --project <slug>', 'Project slug (default: directory name)')
33
+ .action(skillPushCommand)
34
+
35
+ skill.command('install')
36
+ .description('Install a published skill into ~/.claude/skills/<slug>')
37
+ .argument('<slug>', 'Skill slug (see: mdpush skill list)')
38
+ .option('-d, --dest <path>', 'Destination directory (default: ~/.claude/skills/<slug>)')
39
+ .action(skillInstallCommand)
40
+
41
+ skill.command('list')
42
+ .description('List skills published on the server')
43
+ .action(skillListCommand)
44
+
29
45
  program.command('config')
30
46
  .description('Show stored configuration')
31
47
  .action(configCommand)
@@ -1,6 +1,6 @@
1
1
  import { readConfig } from './config-store.js'
2
2
 
3
- /** Make authenticated HTTP request to Preview Reader server */
3
+ /** Make authenticated HTTP request to sielay server */
4
4
  export async function apiRequest(path, options = {}) {
5
5
  const config = readConfig()
6
6
  if (!config.server || !config.token) {