@drawcall/market 0.1.49 → 0.1.51

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.
Files changed (52) hide show
  1. package/README.md +4 -1
  2. package/dist/cli.js +46 -3
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/generate-install.d.ts +6 -0
  5. package/dist/commands/generate-install.d.ts.map +1 -0
  6. package/dist/commands/generate-install.js +29 -0
  7. package/dist/commands/generate-install.js.map +1 -0
  8. package/dist/commands/generate.d.ts +13 -0
  9. package/dist/commands/generate.d.ts.map +1 -1
  10. package/dist/commands/generate.js +57 -20
  11. package/dist/commands/generate.js.map +1 -1
  12. package/dist/commands/pack.d.ts +14 -0
  13. package/dist/commands/pack.d.ts.map +1 -0
  14. package/dist/commands/pack.js +39 -0
  15. package/dist/commands/pack.js.map +1 -0
  16. package/dist/commands/upload.d.ts +0 -12
  17. package/dist/commands/upload.d.ts.map +1 -1
  18. package/dist/commands/upload.js +14 -187
  19. package/dist/commands/upload.js.map +1 -1
  20. package/dist/generate.d.ts +14 -6
  21. package/dist/generate.d.ts.map +1 -1
  22. package/dist/generate.js +30 -19
  23. package/dist/generate.js.map +1 -1
  24. package/dist/index.d.ts +2 -2
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +1 -1
  27. package/dist/index.js.map +1 -1
  28. package/dist/output.d.ts +4 -0
  29. package/dist/output.d.ts.map +1 -1
  30. package/dist/output.js +34 -0
  31. package/dist/output.js.map +1 -1
  32. package/dist/pack.d.ts +48 -0
  33. package/dist/pack.d.ts.map +1 -0
  34. package/dist/pack.js +265 -0
  35. package/dist/pack.js.map +1 -0
  36. package/dist/skill.d.ts +1 -1
  37. package/dist/skill.d.ts.map +1 -1
  38. package/dist/skill.js +3 -2
  39. package/dist/skill.js.map +1 -1
  40. package/package.json +3 -2
  41. package/skills/market/SKILL.md +3 -2
  42. package/src/cli.ts +61 -4
  43. package/src/commands/agent.ts +1 -1
  44. package/src/commands/generate-install.ts +36 -0
  45. package/src/commands/generate.ts +77 -30
  46. package/src/commands/pack.ts +55 -0
  47. package/src/commands/upload.ts +14 -219
  48. package/src/generate.ts +43 -31
  49. package/src/index.ts +2 -2
  50. package/src/output.ts +38 -0
  51. package/src/pack.ts +348 -0
  52. package/src/skill.ts +3 -2
package/src/pack.ts ADDED
@@ -0,0 +1,348 @@
1
+ import * as fs from 'fs/promises'
2
+ import * as path from 'path'
3
+ import { unzipSync, zipSync } from 'fflate'
4
+ import ignore from 'ignore'
5
+ import type { AssetInstallMetadata } from './contract.js'
6
+ import { findInstallRoot } from './install.js'
7
+ import { readMarketLock, sha256 } from './market-lock.js'
8
+ import {
9
+ packageJsonAssetDependenciesFromFiles,
10
+ packageJsonNpmDependenciesFromFiles,
11
+ } from './package-json.js'
12
+ import { MAX_UPLOAD_ZIP_SIZE_BYTES, type AssetType } from './schemas.js'
13
+
14
+ export interface PackDependencySpecs {
15
+ npm?: string[]
16
+ asset?: string[]
17
+ skill?: string[]
18
+ }
19
+
20
+ export interface ParsedPackDependencies {
21
+ npmDependencies: Record<string, string>
22
+ assetDependencies: Record<string, string>
23
+ skillDependencies: Record<string, string>
24
+ }
25
+
26
+ export interface PackPolicy {
27
+ readPackageJsonDependencies: boolean
28
+ omitUnchangedInstalledFiles: boolean
29
+ }
30
+
31
+ export interface PackAssetOptions {
32
+ cwd?: string
33
+ dependencies: ParsedPackDependencies
34
+ policy?: PackPolicy
35
+ }
36
+
37
+ export interface PackedAsset {
38
+ sourcePath: string
39
+ zip: Uint8Array
40
+ npmDependencies: Record<string, string>
41
+ assetDependencies: Record<string, string>
42
+ skillDependencies: Record<string, string>
43
+ omittedUnchangedInstalledFiles: boolean
44
+ /** Number of zip entries dropped because a `.gitignore` inside the zip excluded them. */
45
+ gitignoredFiles: number
46
+ }
47
+
48
+ export async function packAsset(zipFilter: string, opts: PackAssetOptions): Promise<PackedAsset> {
49
+ const cwd = opts.cwd ?? process.cwd()
50
+ const zipFile = await resolveOneZipFile(cwd, zipFilter)
51
+ const zipStat = await fs.stat(zipFile)
52
+ if (zipStat.size >= MAX_UPLOAD_ZIP_SIZE_BYTES) {
53
+ throw new Error('Packed zip must be smaller than 1 GB')
54
+ }
55
+
56
+ const sourceZip = new Uint8Array(await fs.readFile(zipFile))
57
+ const sourceFiles = unzipSync(sourceZip)
58
+ const policy = opts.policy ?? inferPackPolicy(sourceFiles)
59
+ const packageJsonAssetDependencies = policy.readPackageJsonDependencies
60
+ ? packageJsonAssetDependenciesFromFiles(sourceFiles)
61
+ : {}
62
+ const assetDependencies = mergeAssetDependencies(
63
+ packageJsonAssetDependencies,
64
+ opts.dependencies.assetDependencies,
65
+ )
66
+
67
+ const packageJsonNpmDependencies = policy.readPackageJsonDependencies
68
+ ? packageJsonNpmDependenciesFromFiles(sourceFiles)
69
+ : {}
70
+ const npmDependencies = { ...packageJsonNpmDependencies, ...opts.dependencies.npmDependencies }
71
+
72
+ // Drop anything the zip's own .gitignore excludes (node_modules, build output, secrets…), then —
73
+ // for templates — omit unchanged installed dependency files. Only removals happen, so a smaller
74
+ // count means something was dropped; re-zip only then.
75
+ const withoutIgnored = applyGitignore(sourceFiles)
76
+ const files = policy.omitUnchangedInstalledFiles
77
+ ? await withoutUnchangedInstalledFiles(withoutIgnored, assetDependencies, cwd)
78
+ : withoutIgnored
79
+
80
+ const sourceCount = Object.keys(sourceFiles).length
81
+ const afterIgnoreCount = Object.keys(withoutIgnored).length
82
+ const finalCount = Object.keys(files).length
83
+
84
+ return {
85
+ sourcePath: zipFile,
86
+ zip: finalCount < sourceCount ? zipSync(files) : sourceZip,
87
+ npmDependencies,
88
+ assetDependencies,
89
+ skillDependencies: opts.dependencies.skillDependencies,
90
+ omittedUnchangedInstalledFiles: finalCount < afterIgnoreCount,
91
+ gitignoredFiles: sourceCount - afterIgnoreCount,
92
+ }
93
+ }
94
+
95
+ const GITIGNORE_FILENAME = '.gitignore'
96
+
97
+ /**
98
+ * Drop every zip entry excluded by a `.gitignore` inside the zip. `.gitignore` files are applied
99
+ * per-directory (git semantics: a nested `.gitignore` only affects its own subtree), so a naively
100
+ * built asset zip never ships `node_modules`, build output, or secrets.
101
+ */
102
+ function applyGitignore(files: Record<string, Uint8Array>): Record<string, Uint8Array> {
103
+ const matchers = gitignoreMatchers(files)
104
+ if (matchers.length === 0) return files
105
+
106
+ const kept: Record<string, Uint8Array> = {}
107
+ for (const [name, content] of Object.entries(files)) {
108
+ if (!isGitignored(name, matchers)) kept[name] = content
109
+ }
110
+ return kept
111
+ }
112
+
113
+ interface GitignoreMatcher {
114
+ /** Directory the `.gitignore` governs: '' for the zip root, or e.g. 'sub/'. */
115
+ dir: string
116
+ filter: ReturnType<typeof ignore>
117
+ }
118
+
119
+ function gitignoreMatchers(files: Record<string, Uint8Array>): GitignoreMatcher[] {
120
+ const matchers: GitignoreMatcher[] = []
121
+ for (const [name, content] of Object.entries(files)) {
122
+ if (path.posix.basename(name) !== GITIGNORE_FILENAME) continue
123
+ const dir = name.slice(0, name.length - GITIGNORE_FILENAME.length)
124
+ matchers.push({ dir, filter: ignore().add(new TextDecoder().decode(content)) })
125
+ }
126
+ return matchers
127
+ }
128
+
129
+ function isGitignored(name: string, matchers: GitignoreMatcher[]): boolean {
130
+ // fflate keeps directory entries with a trailing slash; the `ignore` lib matches directory patterns
131
+ // (`dist/`) against exactly that form, so the path is passed through unchanged.
132
+ return matchers.some((matcher) => {
133
+ if (!name.startsWith(matcher.dir)) return false
134
+ const relative = name.slice(matcher.dir.length)
135
+ return relative.length > 0 && matcher.filter.ignores(relative)
136
+ })
137
+ }
138
+
139
+ export function parsePackDependencies(specs: PackDependencySpecs): ParsedPackDependencies {
140
+ return {
141
+ npmDependencies: parseVersionedDeps(specs.npm ?? [], 'npm'),
142
+ assetDependencies: parseVersionedDeps(specs.asset ?? [], 'asset'),
143
+ skillDependencies: parseSkillDeps(specs.skill ?? []),
144
+ }
145
+ }
146
+
147
+ export function packPolicyForType(type: AssetType | undefined): PackPolicy | undefined {
148
+ if (!type) return undefined
149
+ return {
150
+ readPackageJsonDependencies: type === 'template',
151
+ omitUnchangedInstalledFiles: type === 'template',
152
+ }
153
+ }
154
+
155
+ export function packPolicyFromInstallMetadata(
156
+ metadata: AssetInstallMetadata | undefined,
157
+ ): PackPolicy {
158
+ return {
159
+ readPackageJsonDependencies: metadata?.readAssetDependenciesFromPackageJson ?? false,
160
+ omitUnchangedInstalledFiles: metadata?.omitUnchangedInstalledFilesOnUpload ?? false,
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Parse `name@range` specs (npm or asset deps) into a name→range record. The
166
+ * range is optional and defaults to `*`. A leading `@` is treated as a scope
167
+ * marker, so `@scope/pkg@^1.0.0` splits into `@scope/pkg` and `^1.0.0`.
168
+ */
169
+ export function parseVersionedDeps(specs: string[], kind: 'npm' | 'asset'): Record<string, string> {
170
+ const out: Record<string, string> = {}
171
+ for (const spec of specs) {
172
+ const at = spec.lastIndexOf('@')
173
+ const hasRange = at > 0
174
+ const name = hasRange ? spec.slice(0, at) : spec
175
+ const range = hasRange ? spec.slice(at + 1) : '*'
176
+ if (!name || !range) {
177
+ throw new Error(`Invalid ${kind} dependency "${spec}". Use name@range (e.g. three@^0.178.0).`)
178
+ }
179
+ if (name in out) {
180
+ throw new Error(`Duplicate ${kind} dependency "${name}".`)
181
+ }
182
+ out[name] = range
183
+ }
184
+ return out
185
+ }
186
+
187
+ /**
188
+ * Parse `label=source` specs into a label→source record. The source is passed
189
+ * verbatim to `skills add` (a GitHub/git ref or a local path), so only the
190
+ * first `=` is treated as the separator.
191
+ */
192
+ export function parseSkillDeps(specs: string[]): Record<string, string> {
193
+ const out: Record<string, string> = {}
194
+ for (const spec of specs) {
195
+ const eq = spec.indexOf('=')
196
+ if (eq <= 0 || eq === spec.length - 1) {
197
+ throw new Error(
198
+ `Invalid skill dependency "${spec}". Use label=source ` +
199
+ `(e.g. web-design=vercel-labs/agent-skills).`,
200
+ )
201
+ }
202
+ const label = spec.slice(0, eq)
203
+ if (label in out) {
204
+ throw new Error(`Duplicate skill dependency "${label}".`)
205
+ }
206
+ out[label] = spec.slice(eq + 1)
207
+ }
208
+ return out
209
+ }
210
+
211
+ function mergeAssetDependencies(
212
+ fromPackageJson: Record<string, string>,
213
+ explicit: Record<string, string>,
214
+ ): Record<string, string> {
215
+ const merged = { ...fromPackageJson }
216
+ for (const [name, range] of Object.entries(explicit)) {
217
+ if (name in merged && merged[name] !== range) {
218
+ throw new Error(
219
+ `Conflicting asset dependency "${name}": package.json has ${merged[name]}, --asset has ${range}`,
220
+ )
221
+ }
222
+ merged[name] = range
223
+ }
224
+ return merged
225
+ }
226
+
227
+ function inferPackPolicy(files: Record<string, Uint8Array>): PackPolicy {
228
+ const hasRootPackageJson = Boolean(files['package.json'])
229
+ return {
230
+ readPackageJsonDependencies: hasRootPackageJson,
231
+ omitUnchangedInstalledFiles: hasRootPackageJson,
232
+ }
233
+ }
234
+
235
+ // Drop dependency files whose bytes still match what `.drawcall/market-lock.json` recorded as
236
+ // installed, so a template ships only its own (edited or new) files. Returns the surviving entries.
237
+ async function withoutUnchangedInstalledFiles(
238
+ files: Record<string, Uint8Array>,
239
+ assetDependencies: Record<string, string>,
240
+ cwd: string,
241
+ ): Promise<Record<string, Uint8Array>> {
242
+ const dependencyNames = new Set(Object.keys(assetDependencies))
243
+ if (dependencyNames.size === 0) return files
244
+
245
+ const installRoot = await findInstallRoot(cwd)
246
+ const lock = await readMarketLock(installRoot)
247
+ const hashesByPath = new Map<string, string>()
248
+
249
+ for (const [name, asset] of Object.entries(lock.assets)) {
250
+ if (!dependencyNames.has(name)) continue
251
+ for (const [file, metadata] of Object.entries(asset.files)) {
252
+ hashesByPath.set(file, metadata.sha256)
253
+ }
254
+ }
255
+
256
+ if (hashesByPath.size === 0) return files
257
+
258
+ const kept: Record<string, Uint8Array> = {}
259
+ for (const [file, content] of Object.entries(files)) {
260
+ const normalizedPath = normalizedZipPath(file)
261
+ const lockedHash = normalizedPath ? hashesByPath.get(normalizedPath) : undefined
262
+ if (lockedHash && lockedHash === sha256(content)) continue
263
+ kept[file] = content
264
+ }
265
+
266
+ return kept
267
+ }
268
+
269
+ function normalizedZipPath(file: string): string | null {
270
+ const zipPath = file.replace(/\\/g, '/')
271
+ if (
272
+ zipPath.split('/').includes('..') ||
273
+ path.posix.isAbsolute(zipPath) ||
274
+ path.win32.isAbsolute(zipPath)
275
+ ) {
276
+ return null
277
+ }
278
+ return path.posix.normalize(zipPath)
279
+ }
280
+
281
+ async function resolveOneZipFile(cwd: string, zipFilter: string): Promise<string> {
282
+ const absolute = path.resolve(cwd, zipFilter)
283
+ const stat = await maybeStat(absolute)
284
+ if (stat?.isFile()) return assertZipFile(absolute)
285
+
286
+ const files = await listFiles(cwd)
287
+ const matches = files
288
+ .filter((file) => matchesFilter(path.relative(cwd, file), zipFilter))
289
+ .filter(isZipFile)
290
+ .sort()
291
+
292
+ if (matches.length === 0) {
293
+ throw new Error(`No .zip files matched "${zipFilter}"`)
294
+ }
295
+ if (matches.length > 1) {
296
+ throw new Error(`File filter matched ${matches.length} zips; pack one asset at a time`)
297
+ }
298
+
299
+ return matches[0]
300
+ }
301
+
302
+ function assertZipFile(file: string): string {
303
+ if (!isZipFile(file)) throw new Error(`Pack source must be a .zip: ${file}`)
304
+ return file
305
+ }
306
+
307
+ function isZipFile(file: string): boolean {
308
+ return /\.zip$/i.test(file)
309
+ }
310
+
311
+ async function maybeStat(file: string) {
312
+ try {
313
+ return await fs.stat(file)
314
+ } catch {
315
+ return null
316
+ }
317
+ }
318
+
319
+ async function listFiles(dir: string): Promise<string[]> {
320
+ const entries = await fs.readdir(dir, { withFileTypes: true })
321
+ const files: string[] = []
322
+ for (const entry of entries) {
323
+ if (entry.name === 'node_modules' || entry.name === '.git') continue
324
+ const fullPath = path.join(dir, entry.name)
325
+ if (entry.isDirectory()) {
326
+ files.push(...(await listFiles(fullPath)))
327
+ } else if (entry.isFile()) {
328
+ files.push(fullPath)
329
+ }
330
+ }
331
+ return files
332
+ }
333
+
334
+ function matchesFilter(file: string, filter: string): boolean {
335
+ const normalizedFile = file.split(path.sep).join('/')
336
+ const normalizedFilter = filter.split(path.sep).join('/')
337
+ const pattern =
338
+ '^' +
339
+ escapeRegExp(normalizedFilter)
340
+ .replace(/\\\*\\\*/g, '.*')
341
+ .replace(/\\\*/g, '[^/]*') +
342
+ '$'
343
+ return new RegExp(pattern).test(normalizedFile)
344
+ }
345
+
346
+ function escapeRegExp(s: string): string {
347
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
348
+ }
package/src/skill.ts CHANGED
@@ -14,6 +14,7 @@ market search "wooden chair" --type model --limit 3
14
14
  market install wooden-chair --cwd "$PWD"
15
15
  market list --cwd "$PWD"
16
16
  market preview wooden-chair --out /tmp/wooden-chair.png
17
+ market pack scene.zip --out scene.packed.zip
17
18
  \`\`\`
18
19
 
19
20
  ## Workflow
@@ -24,8 +25,8 @@ market preview wooden-chair --out /tmp/wooden-chair.png
24
25
  4. \`install\` takes zero or more exact asset names (optionally \`name@range\`). With names, it installs those assets; with no names, it installs \`assetDependencies\` from the nearest \`package.json\`. It does not search or generate. Find names with \`search\` first. No \`--type\` is needed — asset names are unique. Use \`--force\` only when the user agrees to overwrite changed local files.
25
26
  5. \`preview <name>\` saves the preview image; no \`--type\` is needed. Not every type has previews (e.g. \`humanoid-animation\`, \`template\`, \`sound-effect\`, \`background-music\`); the CLI reports when one is unavailable.
26
27
  6. Use \`--unapproved\` only when the user asks for unapproved/private/admin assets. Do not install unapproved assets without explicit acceptance.
27
- 7. \`generate --type <type> "<prompt>"\` creates and installs a generated asset when that asset type has a generator; it requires login. Currently supported generated types are \`sound-effect\`, \`background-music\`, \`flipbook\`, \`humanoid-model\`, and \`environment\` (a fitting HDRI sky + equirectangular background, generated in ~1-2 min). Generation is provider-specific: prompt style, generated files, indexing fields, and install layout are owned by the asset type. If a type does not support generation yet, the CLI reports unsupported generation. Add \`--access public\` to publish the generated asset publicly, or \`--access private\` to keep it owner-only; when omitted the server defaults to private if you hold the \`market:private\` entitlement, else public (\`--access private\` requires that entitlement).
28
- 8. Upload only when publishing is requested: \`market upload <name> <zip> "<description>" --type <type>\`. Declare dependencies with repeatable flags: \`--npm name@range\`, \`--asset name@range\`, \`--skill label=source\`. Template uploads also read root \`package.json.assetDependencies\`; \`--asset\` flags are additive and must not conflict. Template upload omits installed dependency files whose hashes still match \`.drawcall/market-lock.json\`, so edited local files stay in the template. A skill source is a \`skills add\` argument: a whole repo (\`owner/repo\` or a git URL), a single skill via the full URL form \`https://github.com/owner/repo/tree/<branch>/<subpath>\` (the \`tree/<branch>/<subpath>\` shorthand needs the full URL, not \`owner/repo\`), or a local path to a skill directory inside the zip. Example: \`market upload my-scene scene.zip "A scene" --type model --npm three@^0.178.0 --skill web-design=https://github.com/vercel-labs/agent-skills/tree/main/skills/web-design-guidelines\`.
28
+ 7. \`generate --type <type> "<prompt>"\` creates and installs a generated asset when that asset type has a generator; it requires login. Currently supported generated types are \`sound-effect\`, \`background-music\`, \`flipbook\`, \`humanoid-model\`, and \`environment\` (a fitting HDRI sky + equirectangular background, generated in ~1-2 min). Generation is provider-specific: prompt style, generated files, indexing fields, and install layout are owned by the asset type. If a type does not support generation yet, the CLI reports unsupported generation. Add \`--access public\` to publish the generated asset publicly, or \`--access private\` to keep it owner-only; when omitted the server defaults to private if you hold the \`market:private\` entitlement, else public (\`--access private\` requires that entitlement). \`generate\` waits for the asset and installs it — one command for quick types. For a long one (e.g. \`humanoid-model\`, >2 min) the call returns after ~2 min with a job id instead of hanging your shell; run \`market generate install <jobId>\` to continue — it resumes the SAME job where the last call left off and installs when ready. Just re-run \`generate install <jobId>\` until it prints "Generated and installed" (it exits 0 while still generating, 1 on failure). No type is flagged "slow" — anything that outlasts one wait just continues on the next call.
29
+ 8. Use \`pack <zip>\` to create the same Market asset zip that \`upload\` sends. \`pack\` runs offline, infers template packing from a root \`package.json\`, and accepts \`--type\` only when you need to override that inference. \`upload\` runs the shared pack step internally, then publishes: \`market upload <name> <zip> "<description>" --type <type>\`. Declare dependencies with repeatable flags on either command: \`--npm name@range\`, \`--asset name@range\`, \`--skill label=source\`. Template pack/upload also reads root \`package.json.assetDependencies\`; \`--asset\` flags are additive and must not conflict. Template pack/upload omits installed dependency files whose hashes still match \`.drawcall/market-lock.json\`, so edited local files stay in the template. A skill source is a \`skills add\` argument: a whole repo (\`owner/repo\` or a git URL), a single skill via the full URL form \`https://github.com/owner/repo/tree/<branch>/<subpath>\` (the \`tree/<branch>/<subpath>\` shorthand needs the full URL, not \`owner/repo\`), or a local path to a skill directory inside the zip. Example: \`market upload my-scene scene.zip "A scene" --type model --npm three@^0.178.0 --skill web-design=https://github.com/vercel-labs/agent-skills/tree/main/skills/web-design-guidelines\`.
29
30
  9. Installed \`environment\` assets contain \`public/environment/<name>.hdr\` for Three.js IBL lighting and \`public/environment/<name>-background.webp\` for the visible equirectangular background. Use \`market preview\` to fetch the preview image separately.
30
31
  10. Installed \`flipbook\` assets contain \`public/flipbook/<name>.ktx2\`. Render them with \`@drawcall/flipbook\`'s \`Flipbook\` class and Three.js \`KTX2Loader\` for Basis-compressed files; \`market preview\` fetches the middle frame from the flipbook.
31
32