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 +11 -4
- package/assets/scripts/push.sh +18 -6
- package/package.json +1 -1
- package/src/commands/setup-command.js +1 -1
- package/src/commands/skill-command.js +68 -8
- package/src/index.js +9 -2
- package/src/lib/public-request.js +29 -0
package/assets/SKILL.md
CHANGED
|
@@ -1,18 +1,25 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: mdpush
|
|
3
|
-
description: Push markdown, text, and
|
|
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
|
|
12
|
+
# mdpush — Push Markdown, HTML, and PDF Artifacts to Preview Reader
|
|
7
13
|
|
|
8
|
-
Push `.md`, `.txt`, and `.
|
|
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
|
|
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
|
```
|
package/assets/scripts/push.sh
CHANGED
|
@@ -181,13 +181,15 @@ for FILE in "${FILES[@]}"; do
|
|
|
181
181
|
fi
|
|
182
182
|
|
|
183
183
|
BASENAME=$(basename "$FILE")
|
|
184
|
-
|
|
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 .
|
|
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
|
|
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${
|
|
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
|
@@ -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
|
|
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
|
|
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
|
-
/**
|
|
151
|
-
|
|
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)
|
|
155
|
-
|
|
156
|
-
|
|
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(
|
|
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
|
+
}
|