mdpush 1.1.0 → 1.2.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/README.md +2 -2
- package/assets/SKILL.md +1 -0
- package/package.json +11 -1
- package/src/commands/login-command.js +8 -3
- package/src/commands/push-command.js +12 -5
- package/src/commands/skill-command.js +129 -0
- package/src/index.js +21 -1
- package/src/lib/api-request.js +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# mdpush
|
|
2
2
|
|
|
3
|
-
Push markdown files to [
|
|
3
|
+
Push markdown files to [sielay](https://github.com/emiliovos/sielay) from your terminal.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -123,7 +123,7 @@ Generate tokens from the web UI: **Sidebar > API Tokens > Generate Token**.
|
|
|
123
123
|
## Requirements
|
|
124
124
|
|
|
125
125
|
- Node.js >= 18
|
|
126
|
-
- A running [
|
|
126
|
+
- A running [sielay](https://github.com/emiliovos/sielay) server
|
|
127
127
|
|
|
128
128
|
## License
|
|
129
129
|
|
package/assets/SKILL.md
CHANGED
|
@@ -138,3 +138,4 @@ Two viable shapes — decide at implementation time:
|
|
|
138
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
139
|
|
|
140
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
|
+
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mdpush",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=20.0.0"
|
|
@@ -8,6 +8,16 @@
|
|
|
8
8
|
"bin": {
|
|
9
9
|
"mdpush": "./bin/mdpush.js"
|
|
10
10
|
},
|
|
11
|
+
"files": [
|
|
12
|
+
"bin/",
|
|
13
|
+
"src/",
|
|
14
|
+
"assets/",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"prepublishOnly": "bash ./scripts/sync-skill-assets.sh"
|
|
20
|
+
},
|
|
11
21
|
"dependencies": {
|
|
12
22
|
"commander": "^12.0.0",
|
|
13
23
|
"chalk": "^5.3.0",
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
'
|
|
44
|
+
'Cookie': jwtCookie
|
|
40
45
|
},
|
|
41
46
|
body: JSON.stringify({ name: 'mdpush-cli' })
|
|
42
47
|
})
|
|
@@ -8,6 +8,7 @@ import { readConfig } from '../lib/config-store.js'
|
|
|
8
8
|
const HTML_TYPE_WHITELIST = new Set(['spec', 'review', 'design', 'report', 'playground'])
|
|
9
9
|
|
|
10
10
|
const isHtml = (f) => /\.html$/i.test(f)
|
|
11
|
+
const isPdf = (f) => /\.pdf$/i.test(f)
|
|
11
12
|
const isMdTxt = (f) => /\.(md|txt)$/i.test(f)
|
|
12
13
|
|
|
13
14
|
/** Push markdown, text, and HTML artifact files to a project */
|
|
@@ -28,19 +29,21 @@ export async function pushCommand(files, options) {
|
|
|
28
29
|
continue
|
|
29
30
|
}
|
|
30
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.
|
|
31
34
|
const dirFiles = fs.readdirSync(f, { recursive: true })
|
|
32
35
|
.filter(name => isMdTxt(name))
|
|
33
36
|
.map(name => path.join(f, name))
|
|
34
37
|
resolvedFiles.push(...dirFiles)
|
|
35
|
-
} else if (isMdTxt(f) || isHtml(f)) {
|
|
38
|
+
} else if (isMdTxt(f) || isHtml(f) || isPdf(f)) {
|
|
36
39
|
resolvedFiles.push(f)
|
|
37
40
|
} else {
|
|
38
|
-
console.log(chalk.yellow(`Skipping: ${f} (not .md, .txt, or .
|
|
41
|
+
console.log(chalk.yellow(`Skipping: ${f} (not .md, .txt, .html, or .pdf)`))
|
|
39
42
|
}
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
if (resolvedFiles.length === 0) {
|
|
43
|
-
console.log(chalk.red('No .md, .txt, or .
|
|
46
|
+
console.log(chalk.red('No .md, .txt, .html, or .pdf files to upload.'))
|
|
44
47
|
process.exit(1)
|
|
45
48
|
}
|
|
46
49
|
|
|
@@ -67,10 +70,14 @@ export async function pushCommand(files, options) {
|
|
|
67
70
|
formData.append('files', new Blob([content]), basename)
|
|
68
71
|
|
|
69
72
|
const html = isHtml(filePath)
|
|
73
|
+
const pdf = isPdf(filePath)
|
|
70
74
|
const params = new URLSearchParams()
|
|
71
75
|
if (html) {
|
|
72
76
|
params.set('contentType', 'html')
|
|
73
77
|
if (type) params.set('type', type)
|
|
78
|
+
} else if (pdf) {
|
|
79
|
+
params.set('contentType', 'pdf')
|
|
80
|
+
if (folder) params.set('folder', folder)
|
|
74
81
|
} else if (folder) {
|
|
75
82
|
params.set('folder', folder)
|
|
76
83
|
}
|
|
@@ -103,10 +110,10 @@ export async function pushCommand(files, options) {
|
|
|
103
110
|
if (f.url) {
|
|
104
111
|
// HTML artifact: server emits viewer URL
|
|
105
112
|
const badge = f.type ? chalk.magenta(`[${f.type}] `) : ''
|
|
106
|
-
console.log(chalk.cyan(
|
|
113
|
+
console.log(chalk.cyan(`-> ${badge}${f.url} ${f.name}`))
|
|
107
114
|
} else {
|
|
108
115
|
// md/txt: legacy /p/<slug>/f/<sid> path
|
|
109
|
-
console.log(chalk.cyan(
|
|
116
|
+
console.log(chalk.cyan(`-> ${config.server}/p/${project}/f/${f.sid} ${f.name}`))
|
|
110
117
|
}
|
|
111
118
|
}
|
|
112
119
|
}
|
|
@@ -0,0 +1,129 @@
|
|
|
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
|
+
// .html rides the server's dedicated artifact pipe (?contentType=html) —
|
|
16
|
+
// used by assets/preview.html + assets/slides.html (AUTHORING.md standard).
|
|
17
|
+
const SKILL_FILE_EXTENSIONS = new Set(['.md', '.txt', '.py', '.sh', '.js', '.mjs', '.cjs', '.ps1', '.json', '.yaml', '.yml', '.html'])
|
|
18
|
+
|
|
19
|
+
/** Recursive walk: relative POSIX paths, dotfiles + node_modules skipped. */
|
|
20
|
+
function walkSkillDir(root, rel = '') {
|
|
21
|
+
const out = []
|
|
22
|
+
for (const e of fs.readdirSync(path.join(root, rel), { withFileTypes: true })) {
|
|
23
|
+
if (e.name.startsWith('.') || e.name === 'node_modules') continue
|
|
24
|
+
const relPath = rel ? `${rel}/${e.name}` : e.name
|
|
25
|
+
if (e.isDirectory()) out.push(...walkSkillDir(root, relPath))
|
|
26
|
+
else if (e.isFile() && SKILL_FILE_EXTENSIONS.has(path.extname(e.name).toLowerCase())) out.push(relPath)
|
|
27
|
+
}
|
|
28
|
+
return out
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Push a local skill directory to the server as a kind='skill' project. */
|
|
32
|
+
export async function skillPushCommand(dir, options) {
|
|
33
|
+
const root = path.resolve(dir)
|
|
34
|
+
if (!fs.existsSync(path.join(root, 'SKILL.md'))) {
|
|
35
|
+
console.log(chalk.red(`No SKILL.md in ${root} — not a skill directory.`))
|
|
36
|
+
process.exit(2)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const slug = options.project || path.basename(root).toLowerCase().replace(/[^a-z0-9-]+/g, '-')
|
|
40
|
+
const files = walkSkillDir(root)
|
|
41
|
+
if (!files.includes('SKILL.md')) files.unshift('SKILL.md')
|
|
42
|
+
|
|
43
|
+
// Ensure the skill project exists (idempotent: reuse when slug matches)
|
|
44
|
+
const listRes = await apiRequest('/api/projects')
|
|
45
|
+
const projects = await listRes.json()
|
|
46
|
+
let project = projects.find(p => p.slug === slug)
|
|
47
|
+
if (project && project.kind !== 'skill') {
|
|
48
|
+
console.log(chalk.red(`Project "${slug}" exists but is kind='${project.kind}', not a skill. Aborting.`))
|
|
49
|
+
process.exit(2)
|
|
50
|
+
}
|
|
51
|
+
if (!project) {
|
|
52
|
+
const createRes = await apiRequest('/api/projects', {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: { 'Content-Type': 'application/json' },
|
|
55
|
+
body: JSON.stringify({ name: slug, kind: 'skill' })
|
|
56
|
+
})
|
|
57
|
+
project = await createRes.json()
|
|
58
|
+
console.log(chalk.blue(`Created skill project "${project.slug}"`))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
console.log(chalk.blue(`Pushing ${files.length} file(s) to skill "${project.slug}"...`))
|
|
62
|
+
let success = 0, failed = 0
|
|
63
|
+
for (const relPath of files) {
|
|
64
|
+
const spinner = ora(relPath).start()
|
|
65
|
+
try {
|
|
66
|
+
const formData = new FormData()
|
|
67
|
+
formData.append('files', new Blob([fs.readFileSync(path.join(root, relPath))]), path.basename(relPath))
|
|
68
|
+
const folder = path.dirname(relPath)
|
|
69
|
+
const params = new URLSearchParams()
|
|
70
|
+
if (folder && folder !== '.') params.set('folder', folder)
|
|
71
|
+
if (relPath.endsWith('.html')) params.set('contentType', 'html')
|
|
72
|
+
const query = params.size > 0 ? `?${params}` : ''
|
|
73
|
+
await apiRequest(`/api/projects/${project.slug}/upload${query}`, {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
|
76
|
+
body: formData
|
|
77
|
+
})
|
|
78
|
+
spinner.succeed(chalk.green(relPath))
|
|
79
|
+
success++
|
|
80
|
+
} catch (err) {
|
|
81
|
+
spinner.fail(chalk.red(`${relPath}: ${err.message}`))
|
|
82
|
+
failed++
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
console.log(`\n${chalk.green(`${success} pushed`)}${failed ? `, ${chalk.red(`${failed} failed`)}` : ''}`)
|
|
86
|
+
if (failed) process.exit(1)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Install a published skill into ~/.claude/skills/<slug> (or --dest). */
|
|
90
|
+
export async function skillInstallCommand(slug, options) {
|
|
91
|
+
const manifestRes = await apiRequest(`/api/skills/${slug}/manifest`)
|
|
92
|
+
const manifest = await manifestRes.json()
|
|
93
|
+
|
|
94
|
+
const dest = path.resolve(options.dest || path.join(os.homedir(), '.claude', 'skills', slug))
|
|
95
|
+
console.log(chalk.blue(`Installing "${manifest.name || slug}" → ${dest} (${manifest.files.length} files)`))
|
|
96
|
+
|
|
97
|
+
let success = 0, failed = 0
|
|
98
|
+
for (const f of manifest.files) {
|
|
99
|
+
const spinner = ora(f.path).start()
|
|
100
|
+
try {
|
|
101
|
+
// Guard: manifest paths are server-generated, but never trust a path
|
|
102
|
+
// that would escape the destination directory.
|
|
103
|
+
const target = path.join(dest, f.path)
|
|
104
|
+
if (!target.startsWith(dest + path.sep)) throw new Error('Unsafe path in manifest')
|
|
105
|
+
const encoded = f.path.split('/').map(encodeURIComponent).join('/')
|
|
106
|
+
const res = await apiRequest(`/api/projects/${slug}/download/${encoded}`)
|
|
107
|
+
fs.mkdirSync(path.dirname(target), { recursive: true })
|
|
108
|
+
fs.writeFileSync(target, Buffer.from(await res.arrayBuffer()))
|
|
109
|
+
if (target.endsWith('.sh')) { try { fs.chmodSync(target, 0o755) } catch {} }
|
|
110
|
+
spinner.succeed(chalk.green(f.path))
|
|
111
|
+
success++
|
|
112
|
+
} catch (err) {
|
|
113
|
+
spinner.fail(chalk.red(`${f.path}: ${err.message}`))
|
|
114
|
+
failed++
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
console.log(`\n${chalk.green(`${success} installed`)}${failed ? `, ${chalk.red(`${failed} failed`)}` : ''}`)
|
|
118
|
+
if (failed) process.exit(1)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** List published skills. */
|
|
122
|
+
export async function skillListCommand() {
|
|
123
|
+
const res = await apiRequest('/api/skills')
|
|
124
|
+
const skills = await res.json()
|
|
125
|
+
if (skills.length === 0) return console.log(chalk.yellow('No skills published.'))
|
|
126
|
+
for (const s of skills) {
|
|
127
|
+
console.log(`${chalk.cyan(s.slug.padEnd(24))} ${s.description || chalk.gray('(no description)')}`)
|
|
128
|
+
}
|
|
129
|
+
}
|
package/src/index.js
CHANGED
|
@@ -3,10 +3,11 @@ 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 { skillPushCommand, skillInstallCommand, skillListCommand } from './commands/skill-command.js'
|
|
6
7
|
|
|
7
8
|
program
|
|
8
9
|
.name('mdpush')
|
|
9
|
-
.description('Push markdown, text, and HTML artifact files to
|
|
10
|
+
.description('Push markdown, text, and HTML artifact files to sielay')
|
|
10
11
|
.version('1.1.0')
|
|
11
12
|
|
|
12
13
|
program.command('login')
|
|
@@ -22,6 +23,25 @@ program.command('push')
|
|
|
22
23
|
.argument('<files...>', 'Files or directory to upload')
|
|
23
24
|
.action(pushCommand)
|
|
24
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
|
+
|
|
25
45
|
program.command('config')
|
|
26
46
|
.description('Show stored configuration')
|
|
27
47
|
.action(configCommand)
|
package/src/lib/api-request.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readConfig } from './config-store.js'
|
|
2
2
|
|
|
3
|
-
/** Make authenticated HTTP request to
|
|
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) {
|