mdpush 1.1.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
 
@@ -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 [Preview Reader](https://github.com/sielay/preview-reader) server
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.1.0",
3
+ "version": "1.1.1",
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 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
  })
@@ -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 .html)`))
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 .html files to upload.'))
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(`🔗 ${badge}${f.url} ${f.name}`))
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(`🔗 ${config.server}/p/${project}/f/${f.sid} ${f.name}`))
116
+ console.log(chalk.cyan(`-> ${config.server}/p/${project}/f/${f.sid} ${f.name}`))
110
117
  }
111
118
  }
112
119
  }
@@ -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,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 Preview Reader')
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)
@@ -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) {