mdpush 1.3.0 → 1.4.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/assets/SKILL.md CHANGED
@@ -1,18 +1,25 @@
1
1
  ---
2
2
  name: mdpush
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).
3
+ description: Push markdown, text, HTML, and PDF files to Preview Reader / sielay server. Use when user wants to upload .md/.txt/.pdf files to their docs portal OR when user wants to host/share a self-contained HTML artifact (spec, review, design, report, playground).
4
+ metadata:
5
+ category: preview
6
+ example: "/mdpush design.html --type design · /mdpush report.pdf"
7
+ deps: "Config: ~/.mdpush.json con { server, token, defaultProject }. Token desde Preview Reader > API Tokens. Nunca commitear ese archivo. Script: scripts/push.sh."
8
+ installs: []
9
+ risk: none
4
10
  ---
5
11
 
6
- # mdpush — Push Markdown and HTML Artifacts to Preview Reader
12
+ # mdpush — Push Markdown, HTML, and PDF Artifacts to Preview Reader
7
13
 
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.
14
+ Push `.md`, `.txt`, `.html`, and `.pdf` 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; PDFs render in the native viewer at `/v/:sid` (uploaded as binary artifacts via `?contentType=pdf`).
9
15
 
10
16
  ## Usage
11
17
 
12
18
  ```
13
19
  /mdpush <files...> # Push to default project
14
20
  /mdpush -p <project-slug> <files...> # Push to specific project
15
- /mdpush -f <folder> <files...> # Push to subfolder within project (md/txt only)
21
+ /mdpush -f <folder> <files...> # Push to subfolder within project (md/txt/pdf)
22
+ /mdpush report.pdf # Upload a PDF to the native viewer
16
23
  /mdpush design.html --type design # Upload HTML artifact with type tag
17
24
  /mdpush spec.html report.md --type spec # Mix: HTML gets type, .md does not
18
25
  ```
@@ -181,13 +181,15 @@ for FILE in "${FILES[@]}"; do
181
181
  fi
182
182
 
183
183
  BASENAME=$(basename "$FILE")
184
- EXT="${BASENAME##*.}"
184
+ # Lowercase the extension so branch logic (html/pdf) and the whitelist are
185
+ # case-insensitive (.PDF, .HTML, .MD all normalize here).
186
+ EXT=$(printf '%s' "${BASENAME##*.}" | tr '[:upper:]' '[:lower:]')
185
187
 
186
- # Extension whitelist: md, txt, html
188
+ # Extension whitelist: md, txt, html, pdf
187
189
  case "$EXT" in
188
- md|txt|html) ;;
190
+ md|txt|html|pdf) ;;
189
191
  *)
190
- echo "SKIP: $BASENAME (unsupported extension — use .md, .txt, or .html)"
192
+ echo "SKIP: $BASENAME (unsupported extension — use .md, .txt, .html, or .pdf)"
191
193
  continue
192
194
  ;;
193
195
  esac
@@ -300,8 +302,18 @@ for FILE in "${FILES[@]}"; do
300
302
  FAILED=$((FAILED + 1))
301
303
  fi
302
304
 
303
- # --- Markdown / text path: original behavior ---
305
+ # --- Markdown / text / PDF path ---
304
306
  else
307
+ # PDF uploads to the same endpoint but must declare ?contentType=pdf (so the
308
+ # server tags it as a binary artifact, not markdown); md/txt keep the shared
309
+ # ?folder query. Folder, when given, is appended in the matching form.
310
+ if [ "$EXT" = "pdf" ]; then
311
+ NONHTML_QUERY="?contentType=pdf"
312
+ [ -n "$FOLDER" ] && NONHTML_QUERY="${NONHTML_QUERY}&folder=${ENCODED_FOLDER}"
313
+ else
314
+ NONHTML_QUERY="$FOLDER_QUERY"
315
+ fi
316
+
305
317
  t_pre_curl=$(_now_ms)
306
318
  RESPONSE=$(curl -s -w "\n%{http_code}" \
307
319
  --max-time 60 \
@@ -309,7 +321,7 @@ for FILE in "${FILES[@]}"; do
309
321
  -H "Authorization: Bearer $TOKEN" \
310
322
  -H "X-Requested-With: XMLHttpRequest" \
311
323
  -F "files=@$FILE" \
312
- "${SERVER}/api/projects/${PROJECT}/upload${FOLDER_QUERY}") || {
324
+ "${SERVER}/api/projects/${PROJECT}/upload${NONHTML_QUERY}") || {
313
325
  t_post_curl=$(_now_ms)
314
326
  t_end=$(_now_ms)
315
327
  TS=$(date -u +%Y-%m-%dT%H:%M:%S.000Z 2>/dev/null || date -u +%Y-%m-%dT%H:%M:%SZ)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mdpush",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=20.0.0"
@@ -15,7 +15,7 @@ export async function setupCommand() {
15
15
  type: 'text',
16
16
  name: 'server',
17
17
  message: 'Server URL',
18
- initial: 'https://docs.sielay.cloud'
18
+ initial: 'https://sielay.cloud'
19
19
  })
20
20
  if (!server) { console.log(chalk.red('Setup cancelled.')); return }
21
21
 
@@ -4,6 +4,7 @@ import os from 'os'
4
4
  import chalk from 'chalk'
5
5
  import ora from 'ora'
6
6
  import { apiRequest } from '../lib/api-request.js'
7
+ import { publicRequest, resolvePublicServer } from '../lib/public-request.js'
7
8
 
8
9
  /**
9
10
  * `mdpush skill push <dir>` / `mdpush skill install <slug>` — publish a Claude
@@ -114,9 +115,21 @@ export async function skillInstallCommand(slugs, options) {
114
115
  }
115
116
 
116
117
  /** Install one skill. `?source=cli-install` marks a real install so the
117
- * server bumps install_count (detail-page manifest fetches don't). */
118
+ * server bumps install_count (detail-page manifest fetches don't).
119
+ *
120
+ * With `options.public`, fetch anonymously from /public/skills (no login/token)
121
+ * — the aitmpl-style flow. Otherwise use the authenticated /api/skills path. */
118
122
  async function installOneSkill(slug, options) {
119
- const manifestRes = await apiRequest(`/api/skills/${slug}/manifest?source=cli-install`)
123
+ const anon = !!options.public
124
+ const server = anon ? resolvePublicServer(options.server) : null
125
+ const fetchJson = anon
126
+ ? p => publicRequest(server, `/public/skills/${slug}${p}`)
127
+ : p => apiRequest(`/api/skills/${slug}${p}`)
128
+ const fetchFile = anon
129
+ ? encoded => publicRequest(server, `/public/skills/${slug}/download/${encoded}`)
130
+ : encoded => apiRequest(`/api/projects/${slug}/download/${encoded}`)
131
+
132
+ const manifestRes = await fetchJson('/manifest?source=cli-install')
120
133
  const manifest = await manifestRes.json()
121
134
 
122
135
  const dest = path.resolve(options.dest || path.join(os.homedir(), '.claude', 'skills', slug))
@@ -131,7 +144,7 @@ async function installOneSkill(slug, options) {
131
144
  const target = path.join(dest, f.path)
132
145
  if (!target.startsWith(dest + path.sep)) throw new Error('Unsafe path in manifest')
133
146
  const encoded = f.path.split('/').map(encodeURIComponent).join('/')
134
- const res = await apiRequest(`/api/projects/${slug}/download/${encoded}`)
147
+ const res = await fetchFile(encoded)
135
148
  fs.mkdirSync(path.dirname(target), { recursive: true })
136
149
  fs.writeFileSync(target, Buffer.from(await res.arrayBuffer()))
137
150
  if (target.endsWith('.sh')) { try { fs.chmodSync(target, 0o755) } catch {} }
@@ -147,12 +160,59 @@ async function installOneSkill(slug, options) {
147
160
  if (failed) throw new Error(`${failed} file(s) failed`)
148
161
  }
149
162
 
150
- /** List published skills. */
151
- export async function skillListCommand() {
163
+ /** Fetch + print the catalog table. Returns the skills array (or []). */
164
+ async function printCatalog({ numbered = false } = {}) {
152
165
  const res = await apiRequest('/api/skills')
153
166
  const skills = await res.json()
154
- if (skills.length === 0) return console.log(chalk.yellow('No skills published.'))
155
- for (const s of skills) {
156
- console.log(`${chalk.cyan(s.slug.padEnd(24))} ${s.description || chalk.gray('(no description)')}`)
167
+ if (skills.length === 0) {
168
+ console.log(chalk.yellow('No skills published.'))
169
+ return []
170
+ }
171
+ skills.forEach((s, i) => {
172
+ const idx = numbered ? chalk.gray(`${String(i + 1).padStart(3)}. `) : ''
173
+ const cat = (s.category || '—').padEnd(10)
174
+ const installs = String(s.installCount ?? 0).padStart(4)
175
+ const desc = (s.description || '(no description)').slice(0, 80)
176
+ console.log(`${idx}${chalk.cyan(s.slug.padEnd(24))} ${chalk.magenta(cat)} ${chalk.gray(`⤓${installs}`)} ${desc}`)
177
+ })
178
+ return skills
179
+ }
180
+
181
+ /** List published skills (slug · category · installs · description). */
182
+ export async function skillListCommand() {
183
+ await printCatalog()
184
+ }
185
+
186
+ /**
187
+ * Interactive browse (`mdpush skill` with no subcommand) — numbered catalog,
188
+ * pick by number/comma list, installs the selection. Zero-dependency prompt
189
+ * via node:readline; falls back to plain list when stdin isn't a TTY.
190
+ */
191
+ export async function skillBrowseCommand() {
192
+ const skills = await printCatalog({ numbered: true })
193
+ if (skills.length === 0 || !process.stdin.isTTY) return
194
+
195
+ const readline = await import('node:readline/promises')
196
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
197
+ // Race against 'close' — an EOF on stdin (Ctrl-D, piped input running dry)
198
+ // would otherwise leave the question promise pending forever.
199
+ const answer = (await Promise.race([
200
+ rl.question(chalk.blue('\nInstall which? (numbers, comma-separated — empty to exit): ')),
201
+ new Promise(resolve => rl.once('close', () => resolve('')))
202
+ ])).trim()
203
+ rl.close()
204
+ // A TTY stdin touched by readline keeps the event loop alive after close —
205
+ // exit explicitly on every path (install failures already exit(1) upstream).
206
+ if (!answer) process.exit(0)
207
+
208
+ const picks = [...new Set(answer.split(/[\s,]+/))]
209
+ .map(n => skills[parseInt(n, 10) - 1])
210
+ .filter(Boolean)
211
+ .map(s => s.slug)
212
+ if (picks.length === 0) {
213
+ console.log(chalk.yellow('No valid selection.'))
214
+ process.exit(2)
157
215
  }
216
+ await skillInstallCommand(picks, {})
217
+ process.exit(0)
158
218
  }
package/src/index.js CHANGED
@@ -1,14 +1,18 @@
1
+ import { createRequire } from 'module'
1
2
  import { program } from 'commander'
3
+
4
+ // Single-source the CLI version from package.json (was hardcoded and stale)
5
+ const pkg = createRequire(import.meta.url)('../package.json')
2
6
  import { loginCommand } from './commands/login-command.js'
3
7
  import { pushCommand } from './commands/push-command.js'
4
8
  import { configCommand } from './commands/config-command.js'
5
9
  import { logoutCommand } from './commands/logout-command.js'
6
- import { skillPushCommand, skillInstallCommand, skillListCommand } from './commands/skill-command.js'
10
+ import { skillPushCommand, skillInstallCommand, skillListCommand, skillBrowseCommand } from './commands/skill-command.js'
7
11
 
8
12
  program
9
13
  .name('mdpush')
10
14
  .description('Push markdown, text, and HTML artifact files to sielay')
11
- .version('1.1.0')
15
+ .version(pkg.version)
12
16
 
13
17
  program.command('login')
14
18
  .description('Authenticate with server')
@@ -25,6 +29,7 @@ program.command('push')
25
29
 
26
30
  const skill = program.command('skill')
27
31
  .description('Publish and install Claude Code skill bundles (projects with kind=skill)')
32
+ .action(skillBrowseCommand) // bare `mdpush skill` → interactive browse
28
33
 
29
34
  skill.command('push')
30
35
  .description('Push a skill directory (must contain SKILL.md) to the server')
@@ -36,6 +41,8 @@ skill.command('install')
36
41
  .description('Install published skills into ~/.claude/skills/<slug>')
37
42
  .argument('<slugs...>', 'Skill slug(s) — space or comma separated (see: mdpush skill list)')
38
43
  .option('-d, --dest <path>', 'Destination directory (single skill only)')
44
+ .option('--public', 'Install anonymously from /public/skills (no login required)')
45
+ .option('-s, --server <url>', 'Server URL for --public (default: stored config or https://docs.sielay.cloud)')
39
46
  .action(skillInstallCommand)
40
47
 
41
48
  skill.command('list')
@@ -0,0 +1,29 @@
1
+ import { readConfig } from './config-store.js'
2
+
3
+ /** Default public sielay instance when neither --server nor a login config set one. */
4
+ const DEFAULT_PUBLIC_SERVER = 'https://docs.sielay.cloud'
5
+
6
+ /**
7
+ * Resolve the base server URL for anonymous (public) requests. Precedence:
8
+ * explicit --server → stored login config server → the default public host.
9
+ * No token is read or sent — these hit /public/* which needs no credential.
10
+ */
11
+ export function resolvePublicServer(optServer) {
12
+ if (optServer) return optServer.replace(/\/$/, '')
13
+ const cfg = readConfig()
14
+ return (cfg.server || DEFAULT_PUBLIC_SERVER).replace(/\/$/, '')
15
+ }
16
+
17
+ /** Unauthenticated GET against a sielay /public/* path. */
18
+ export async function publicRequest(server, path) {
19
+ const res = await fetch(`${server}${path}`)
20
+ if (!res.ok) {
21
+ // /public/* returns a uniform 404 for missing / not-public / kill-switch-off,
22
+ // so surface a single actionable message rather than guessing the cause.
23
+ if (res.status === 404) {
24
+ throw new Error('Not found (skill not public, or public skills disabled on this server)')
25
+ }
26
+ throw new Error(`HTTP ${res.status}`)
27
+ }
28
+ return res
29
+ }