happyskills 0.29.0 → 0.30.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/CHANGELOG.md CHANGED
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.30.0] - 2026-04-03
11
+
12
+ ### Added
13
+ - Add archive-based install for large skills — installer tries `?format=archive` clone first (single `.tar.gz` download via presigned URL), falls back to JSON clone transparently
14
+ - Add 1MB per-file size validation rule — enforced in `validate` and `publish` commands, blocks oversized files before upload
15
+
10
16
  ## [0.29.0] - 2026-04-02
11
17
 
12
18
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "Package manager for AI agent skills",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Nicolas Dao <nic@cloudlesslabs.com> (https://cloudlesslabs.com)",
@@ -45,7 +45,8 @@
45
45
  "dependencies": {
46
46
  "node-diff3": "^3.2.0",
47
47
  "puffy-core": "^1.3.1",
48
- "semver": "^7.6.0"
48
+ "semver": "^7.6.0",
49
+ "tar-stream": "^3.1.8"
49
50
  },
50
51
  "devDependencies": {
51
52
  "dotenv": "^17.2.4"
package/src/api/repos.js CHANGED
@@ -26,6 +26,7 @@ const clone = (owner, repo, ref, options = {}) => catch_errors(`Clone ${owner}/$
26
26
  const params = new URLSearchParams()
27
27
  if (options.commit) params.set('commit', options.commit)
28
28
  else if (ref) params.set('ref', ref)
29
+ if (options.format) params.set('format', options.format)
29
30
  const qs = params.toString() ? `?${params}` : ''
30
31
  const [errors, data] = await client.get(`/repos/${owner}/${repo}/clone${qs}`)
31
32
  if (errors) throw errors[errors.length - 1]
@@ -17,6 +17,7 @@ const { validate_skill_md } = require('../validation/skill_md_rules')
17
17
  const { validate_skill_json } = require('../validation/skill_json_rules')
18
18
  const { validate_cross } = require('../validation/cross_rules')
19
19
  const { validate_no_conflict_markers } = require('../validation/conflict_marker_rules')
20
+ const { validate_file_sizes } = require('../validation/file_size_rules')
20
21
  const { create_spinner } = require('../ui/spinner')
21
22
  const { print_help, print_success, print_error, print_warn, print_hint, print_json, code, summarize_warnings } = require('../ui/output')
22
23
  const { exit_with_error, UsageError, CliError } = require('../utils/errors')
@@ -103,8 +104,10 @@ const run = (args) => catch_errors('Publish failed', async () => {
103
104
  if (cross_err) throw cross_err
104
105
  const [marker_err, marker_results] = await validate_no_conflict_markers(dir)
105
106
  if (marker_err) throw marker_err
107
+ const [size_err, size_results] = await validate_file_sizes(dir)
108
+ if (size_err) throw size_err
106
109
 
107
- const all_results = [...md_data.results, ...json_data.results, ...cross_results, ...marker_results]
110
+ const all_results = [...md_data.results, ...json_data.results, ...cross_results, ...marker_results, ...size_results]
108
111
  const validation_errors = all_results.filter(r => r.severity === 'error')
109
112
  const validation_warnings = all_results.filter(r => r.severity === 'warning')
110
113
 
@@ -4,6 +4,7 @@ const { validate_skill_md } = require('../validation/skill_md_rules')
4
4
  const { validate_skill_json } = require('../validation/skill_json_rules')
5
5
  const { validate_cross } = require('../validation/cross_rules')
6
6
  const { validate_no_conflict_markers } = require('../validation/conflict_marker_rules')
7
+ const { validate_file_sizes } = require('../validation/file_size_rules')
7
8
  const { file_exists, read_json } = require('../utils/fs')
8
9
  const { skills_dir, find_project_root } = require('../config/paths')
9
10
  const { print_help, print_json } = require('../ui/output')
@@ -147,8 +148,10 @@ const run = (args) => catch_errors('Validate failed', async () => {
147
148
  if (cross_err) throw cross_err
148
149
  const [marker_err, marker_results] = await validate_no_conflict_markers(skill_dir)
149
150
  if (marker_err) throw marker_err
151
+ const [size_err, size_results] = await validate_file_sizes(skill_dir)
152
+ if (size_err) throw size_err
150
153
 
151
- const all_results = [...md_data.results, ...json_data.results, ...cross_results, ...marker_results]
154
+ const all_results = [...md_data.results, ...json_data.results, ...cross_results, ...marker_results, ...size_results]
152
155
  const type_label = is_kit ? ' [kit]' : ''
153
156
 
154
157
  if (args.flags.json) {
@@ -0,0 +1,87 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const zlib = require('zlib')
4
+ const { Readable } = require('stream')
5
+ const tar = require('tar-stream')
6
+ const { error: { catch_errors } } = require('puffy-core')
7
+ const repos_api = require('../api/repos')
8
+
9
+ /**
10
+ * Attempts an archive-based clone. Returns the extracted files or null if no archive is available.
11
+ *
12
+ * @param {string} owner - Workspace slug
13
+ * @param {string} name - Repo name
14
+ * @param {string} ref - Git ref (e.g., refs/tags/v1.0.0)
15
+ * @param {string} dest_dir - Directory to extract files into
16
+ * @returns {[errors, {ref, commit}|null]} — null if no archive available (caller should fall back to JSON clone)
17
+ */
18
+ const install_from_archive = (owner, name, ref, dest_dir) => catch_errors('Archive install failed', async () => {
19
+ // Request archive format
20
+ const [clone_err, clone_data] = await repos_api.clone(owner, name, ref, { format: 'archive' })
21
+ if (clone_err) return null
22
+ if (!clone_data || clone_data.format !== 'archive' || !clone_data.url) return null
23
+
24
+ // Download archive from presigned URL
25
+ const resp = await fetch(clone_data.url)
26
+ if (!resp.ok) throw new Error(`Archive download failed: ${resp.status}`)
27
+
28
+ const archive_buffer = Buffer.from(await resp.arrayBuffer())
29
+
30
+ // Extract tar.gz to dest_dir
31
+ await extract_tar_gz(archive_buffer, dest_dir)
32
+
33
+ return { ref: clone_data.ref, commit: clone_data.commit }
34
+ })
35
+
36
+ /**
37
+ * Extracts a .tar.gz buffer into a directory.
38
+ */
39
+ const extract_tar_gz = (buffer, dest_dir) => new Promise((resolve, reject) => {
40
+ const extract = tar.extract()
41
+
42
+ extract.on('entry', (header, stream, next) => {
43
+ if (header.type !== 'file') {
44
+ stream.resume()
45
+ return next()
46
+ }
47
+
48
+ // Normalize path: strip leading ./
49
+ const file_path = header.name.replace(/^\.\//, '')
50
+
51
+ // Skip macOS metadata
52
+ const basename = file_path.split('/').pop()
53
+ if (basename.startsWith('._') || basename === '.DS_Store') {
54
+ stream.resume()
55
+ return next()
56
+ }
57
+
58
+ // Path safety check
59
+ const resolved = path.resolve(dest_dir, file_path)
60
+ if (!resolved.startsWith(path.resolve(dest_dir) + path.sep) && resolved !== path.resolve(dest_dir)) {
61
+ stream.resume()
62
+ return next()
63
+ }
64
+
65
+ const chunks = []
66
+ stream.on('data', chunk => chunks.push(chunk))
67
+ stream.on('end', async () => {
68
+ try {
69
+ const dir = path.dirname(resolved)
70
+ await fs.promises.mkdir(dir, { recursive: true })
71
+ await fs.promises.writeFile(resolved, Buffer.concat(chunks))
72
+ next()
73
+ } catch (err) {
74
+ reject(err)
75
+ }
76
+ })
77
+ stream.on('error', reject)
78
+ })
79
+
80
+ extract.on('finish', resolve)
81
+ extract.on('error', reject)
82
+
83
+ const gunzip = zlib.createGunzip()
84
+ Readable.from(buffer).pipe(gunzip).pipe(extract)
85
+ })
86
+
87
+ module.exports = { install_from_archive, extract_tar_gz }
@@ -0,0 +1,121 @@
1
+ const { describe, it, beforeEach, afterEach } = require('node:test')
2
+ const assert = require('node:assert/strict')
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const os = require('os')
6
+ const zlib = require('zlib')
7
+ const tar = require('tar-stream')
8
+ const { extract_tar_gz } = require('./archive_installer')
9
+
10
+ const create_tar_gz = (files) => new Promise((resolve, reject) => {
11
+ const pack = tar.pack()
12
+ for (const { name, content, type } of files) {
13
+ if (type) {
14
+ pack.entry({ name, type })
15
+ } else {
16
+ pack.entry({ name }, content)
17
+ }
18
+ }
19
+ pack.finalize()
20
+ const chunks = []
21
+ const gz = zlib.createGzip()
22
+ pack.pipe(gz)
23
+ gz.on('data', chunk => chunks.push(chunk))
24
+ gz.on('end', () => resolve(Buffer.concat(chunks)))
25
+ gz.on('error', reject)
26
+ })
27
+
28
+ describe('extract_tar_gz', () => {
29
+ let tmp_dir
30
+
31
+ beforeEach(async () => {
32
+ tmp_dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'archive-test-'))
33
+ })
34
+
35
+ afterEach(async () => {
36
+ await fs.promises.rm(tmp_dir, { recursive: true, force: true })
37
+ })
38
+
39
+ it('extracts files to destination directory', async () => {
40
+ const buffer = await create_tar_gz([
41
+ { name: 'hello.txt', content: 'Hello world' },
42
+ { name: 'data.json', content: '{"ok":true}' }
43
+ ])
44
+
45
+ await extract_tar_gz(buffer, tmp_dir)
46
+
47
+ const hello = await fs.promises.readFile(path.join(tmp_dir, 'hello.txt'), 'utf8')
48
+ const data = await fs.promises.readFile(path.join(tmp_dir, 'data.json'), 'utf8')
49
+ assert.equal(hello, 'Hello world')
50
+ assert.equal(data, '{"ok":true}')
51
+ })
52
+
53
+ it('creates nested directories', async () => {
54
+ const buffer = await create_tar_gz([
55
+ { name: 'sub/file.txt', content: 'nested content' }
56
+ ])
57
+
58
+ await extract_tar_gz(buffer, tmp_dir)
59
+
60
+ const content = await fs.promises.readFile(path.join(tmp_dir, 'sub', 'file.txt'), 'utf8')
61
+ assert.equal(content, 'nested content')
62
+
63
+ const stat = await fs.promises.stat(path.join(tmp_dir, 'sub'))
64
+ assert.ok(stat.isDirectory())
65
+ })
66
+
67
+ it('skips macOS metadata files', async () => {
68
+ const buffer = await create_tar_gz([
69
+ { name: '._hidden', content: 'mac metadata' },
70
+ { name: '.DS_Store', content: 'store data' },
71
+ { name: 'sub/._resource', content: 'nested mac metadata' },
72
+ { name: 'real.txt', content: 'keep me' }
73
+ ])
74
+
75
+ await extract_tar_gz(buffer, tmp_dir)
76
+
77
+ assert.equal(fs.existsSync(path.join(tmp_dir, '._hidden')), false)
78
+ assert.equal(fs.existsSync(path.join(tmp_dir, '.DS_Store')), false)
79
+ assert.equal(fs.existsSync(path.join(tmp_dir, 'sub', '._resource')), false)
80
+
81
+ const content = await fs.promises.readFile(path.join(tmp_dir, 'real.txt'), 'utf8')
82
+ assert.equal(content, 'keep me')
83
+ })
84
+
85
+ it('skips non-file entries', async () => {
86
+ const buffer = await create_tar_gz([
87
+ { name: 'somedir/', type: 'directory' },
88
+ { name: 'actual.txt', content: 'file content' }
89
+ ])
90
+
91
+ await extract_tar_gz(buffer, tmp_dir)
92
+
93
+ const content = await fs.promises.readFile(path.join(tmp_dir, 'actual.txt'), 'utf8')
94
+ assert.equal(content, 'file content')
95
+ })
96
+
97
+ it('rejects path traversal', async () => {
98
+ const buffer = await create_tar_gz([
99
+ { name: '../evil.txt', content: 'malicious' },
100
+ { name: 'safe.txt', content: 'safe content' }
101
+ ])
102
+
103
+ await extract_tar_gz(buffer, tmp_dir)
104
+
105
+ assert.equal(fs.existsSync(path.join(tmp_dir, '..', 'evil.txt')), false)
106
+
107
+ const content = await fs.promises.readFile(path.join(tmp_dir, 'safe.txt'), 'utf8')
108
+ assert.equal(content, 'safe content')
109
+ })
110
+
111
+ it('strips leading ./ from paths', async () => {
112
+ const buffer = await create_tar_gz([
113
+ { name: './file.txt', content: 'dot-slash content' }
114
+ ])
115
+
116
+ await extract_tar_gz(buffer, tmp_dir)
117
+
118
+ const content = await fs.promises.readFile(path.join(tmp_dir, 'file.txt'), 'utf8')
119
+ assert.equal(content, 'dot-slash content')
120
+ })
121
+ })
@@ -108,11 +108,24 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
108
108
  const pkg_tmp = path.join(temp_dir, name)
109
109
 
110
110
  spinner.update(`Downloading ${pkg.skill}@${pkg.version}...`)
111
- const [dl_errors, clone_data] = await download(owner, name, pkg.ref)
112
- if (dl_errors) { spinner.fail(`Download failed: ${pkg.skill}`); throw e(`Download ${pkg.skill} failed`, dl_errors) }
113
111
 
114
- const [ext_errors] = await extract(clone_data, pkg_tmp)
115
- if (ext_errors) { spinner.fail(`Extract failed: ${pkg.skill}`); throw e(`Extract ${pkg.skill} failed`, ext_errors) }
112
+ // Try archive-based install first (fast single download), fall back to JSON clone
113
+ const { install_from_archive } = require('./archive_installer')
114
+ const [arch_err, arch_result] = await install_from_archive(owner, name, pkg.ref, pkg_tmp)
115
+ let clone_ref, clone_commit
116
+
117
+ if (!arch_err && arch_result) {
118
+ // Archive install succeeded
119
+ clone_ref = arch_result.ref
120
+ clone_commit = arch_result.commit
121
+ } else {
122
+ // Fall back to JSON clone
123
+ const [dl_errors, clone_data] = await download(owner, name, pkg.ref)
124
+ if (dl_errors) { spinner.fail(`Download failed: ${pkg.skill}`); throw e(`Download ${pkg.skill} failed`, dl_errors) }
125
+
126
+ const [ext_errors] = await extract(clone_data, pkg_tmp)
127
+ if (ext_errors) { spinner.fail(`Extract failed: ${pkg.skill}`); throw e(`Extract ${pkg.skill} failed`, ext_errors) }
128
+ }
116
129
 
117
130
  if (pkg.integrity) {
118
131
  const [, valid] = await verify_integrity(pkg_tmp, pkg.integrity)
@@ -0,0 +1,48 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const { error: { catch_errors } } = require('puffy-core')
4
+
5
+ const MAX_FILE_SIZE = 1 * 1024 * 1024 // 1MB
6
+
7
+ const result = (file, actual_size) => ({
8
+ file,
9
+ field: null,
10
+ rule: 'max_file_size',
11
+ severity: 'error',
12
+ message: `File exceeds 1MB limit (${(actual_size / (1024 * 1024)).toFixed(2)}MB)`
13
+ })
14
+
15
+ /**
16
+ * Scans all files in a skill directory and checks that none exceed the max file size.
17
+ * Returns error results for each file that exceeds the limit.
18
+ * Skips dotfiles and common non-publishable directories (consistent with hash_directory exclusions).
19
+ *
20
+ * @param {string} skill_dir - Absolute path to the skill directory
21
+ * @returns {[errors, results[]]} — results with severity 'error' for each oversized file
22
+ */
23
+ const validate_file_sizes = (skill_dir) => catch_errors('Failed to check file sizes', async () => {
24
+ const results = []
25
+ const walk = async (dir, prefix) => {
26
+ let items
27
+ try { items = await fs.promises.readdir(dir, { withFileTypes: true }) } catch { return }
28
+ for (const item of items) {
29
+ if (item.name.startsWith('.')) continue
30
+ if (item.name === 'node_modules') continue
31
+ const rel = prefix ? `${prefix}/${item.name}` : item.name
32
+ const full = path.join(dir, item.name)
33
+ if (item.isDirectory()) {
34
+ await walk(full, rel)
35
+ } else {
36
+ let stat
37
+ try { stat = await fs.promises.stat(full) } catch { continue }
38
+ if (stat.size > MAX_FILE_SIZE) {
39
+ results.push(result(rel, stat.size))
40
+ }
41
+ }
42
+ }
43
+ }
44
+ await walk(skill_dir, '')
45
+ return results
46
+ })
47
+
48
+ module.exports = { validate_file_sizes }
@@ -0,0 +1,84 @@
1
+ const { describe, it, beforeEach, afterEach } = require('node:test')
2
+ const assert = require('node:assert/strict')
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const os = require('os')
6
+ const { validate_file_sizes } = require('./file_size_rules')
7
+
8
+ const MAX_FILE_SIZE = 1 * 1024 * 1024 // 1MB
9
+
10
+ const make_temp_dir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'file-size-test-'))
11
+ const clean = (dir) => fs.rmSync(dir, { recursive: true, force: true })
12
+
13
+ describe('validate_file_sizes', () => {
14
+ let dir
15
+
16
+ beforeEach(() => { dir = make_temp_dir() })
17
+ afterEach(() => { clean(dir) })
18
+
19
+ it('returns empty for files under 1MB limit', async () => {
20
+ fs.writeFileSync(path.join(dir, 'small.txt'), 'hello world')
21
+ const [err, results] = await validate_file_sizes(dir)
22
+ assert.strictEqual(err, null)
23
+ assert.strictEqual(results.length, 0)
24
+ })
25
+
26
+ it('returns error for file exceeding 1MB', async () => {
27
+ fs.writeFileSync(path.join(dir, 'big.txt'), Buffer.alloc(MAX_FILE_SIZE + 1))
28
+ const [err, results] = await validate_file_sizes(dir)
29
+ assert.strictEqual(err, null)
30
+ assert.strictEqual(results.length, 1)
31
+ assert.strictEqual(results[0].severity, 'error')
32
+ assert.strictEqual(results[0].rule, 'max_file_size')
33
+ assert.strictEqual(results[0].file, 'big.txt')
34
+ })
35
+
36
+ it('handles multiple oversized files', async () => {
37
+ fs.writeFileSync(path.join(dir, 'big1.txt'), Buffer.alloc(MAX_FILE_SIZE + 1))
38
+ fs.writeFileSync(path.join(dir, 'big2.txt'), Buffer.alloc(MAX_FILE_SIZE + 100))
39
+ const [err, results] = await validate_file_sizes(dir)
40
+ assert.strictEqual(err, null)
41
+ assert.strictEqual(results.length, 2)
42
+ assert.ok(results.every(r => r.severity === 'error'))
43
+ assert.ok(results.every(r => r.rule === 'max_file_size'))
44
+ })
45
+
46
+ it('reports correct relative path for nested files', async () => {
47
+ const sub = path.join(dir, 'sub')
48
+ fs.mkdirSync(sub)
49
+ fs.writeFileSync(path.join(sub, 'big.txt'), Buffer.alloc(MAX_FILE_SIZE + 1))
50
+ const [err, results] = await validate_file_sizes(dir)
51
+ assert.strictEqual(err, null)
52
+ assert.strictEqual(results.length, 1)
53
+ assert.strictEqual(results[0].file, 'sub/big.txt')
54
+ })
55
+
56
+ it('skips dotfiles', async () => {
57
+ fs.writeFileSync(path.join(dir, '.hidden'), Buffer.alloc(MAX_FILE_SIZE + 1))
58
+ const [err, results] = await validate_file_sizes(dir)
59
+ assert.strictEqual(err, null)
60
+ assert.strictEqual(results.length, 0)
61
+ })
62
+
63
+ it('skips node_modules', async () => {
64
+ const nm = path.join(dir, 'node_modules')
65
+ fs.mkdirSync(nm)
66
+ fs.writeFileSync(path.join(nm, 'big.txt'), Buffer.alloc(MAX_FILE_SIZE + 1))
67
+ const [err, results] = await validate_file_sizes(dir)
68
+ assert.strictEqual(err, null)
69
+ assert.strictEqual(results.length, 0)
70
+ })
71
+
72
+ it('passes for file exactly at 1MB limit', async () => {
73
+ fs.writeFileSync(path.join(dir, 'exact.txt'), Buffer.alloc(MAX_FILE_SIZE))
74
+ const [err, results] = await validate_file_sizes(dir)
75
+ assert.strictEqual(err, null)
76
+ assert.strictEqual(results.length, 0)
77
+ })
78
+
79
+ it('handles empty directory', async () => {
80
+ const [err, results] = await validate_file_sizes(dir)
81
+ assert.strictEqual(err, null)
82
+ assert.strictEqual(results.length, 0)
83
+ })
84
+ })