@drawcall/market 0.1.35 → 0.1.37

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 (63) hide show
  1. package/README.md +6 -3
  2. package/dist/asset-implementation.d.ts +16 -7
  3. package/dist/asset-implementation.d.ts.map +1 -1
  4. package/dist/asset-implementation.js +20 -1
  5. package/dist/asset-implementation.js.map +1 -1
  6. package/dist/cli.js +14 -2
  7. package/dist/cli.js.map +1 -1
  8. package/dist/commands/install.d.ts +3 -4
  9. package/dist/commands/install.d.ts.map +1 -1
  10. package/dist/commands/install.js +57 -11
  11. package/dist/commands/install.js.map +1 -1
  12. package/dist/commands/list.d.ts +17 -0
  13. package/dist/commands/list.d.ts.map +1 -0
  14. package/dist/commands/list.js +26 -0
  15. package/dist/commands/list.js.map +1 -0
  16. package/dist/commands/upload.d.ts.map +1 -1
  17. package/dist/commands/upload.js +64 -2
  18. package/dist/commands/upload.js.map +1 -1
  19. package/dist/contract.d.ts +9 -1
  20. package/dist/contract.d.ts.map +1 -1
  21. package/dist/contract.js +1 -1
  22. package/dist/contract.js.map +1 -1
  23. package/dist/index.d.ts +5 -2
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +2 -0
  26. package/dist/index.js.map +1 -1
  27. package/dist/install.d.ts +14 -0
  28. package/dist/install.d.ts.map +1 -1
  29. package/dist/install.js +133 -23
  30. package/dist/install.js.map +1 -1
  31. package/dist/market-lock.d.ts +17 -0
  32. package/dist/market-lock.d.ts.map +1 -0
  33. package/dist/market-lock.js +44 -0
  34. package/dist/market-lock.js.map +1 -0
  35. package/dist/output.d.ts +4 -2
  36. package/dist/output.d.ts.map +1 -1
  37. package/dist/output.js +28 -7
  38. package/dist/output.js.map +1 -1
  39. package/dist/package-json.d.ts +11 -0
  40. package/dist/package-json.d.ts.map +1 -0
  41. package/dist/package-json.js +23 -0
  42. package/dist/package-json.js.map +1 -0
  43. package/dist/skill.d.ts +1 -1
  44. package/dist/skill.d.ts.map +1 -1
  45. package/dist/skill.js +20 -9
  46. package/dist/skill.js.map +1 -1
  47. package/package.json +2 -2
  48. package/src/asset-implementation.ts +40 -6
  49. package/src/cli.ts +24 -6
  50. package/src/commands/install.ts +76 -16
  51. package/src/commands/list.ts +47 -0
  52. package/src/commands/upload.ts +84 -2
  53. package/src/contract.ts +10 -1
  54. package/src/index.ts +15 -0
  55. package/src/install.ts +197 -35
  56. package/src/market-lock.ts +65 -0
  57. package/src/output.ts +36 -8
  58. package/src/package-json.ts +37 -0
  59. package/src/skill.ts +20 -9
  60. package/tests/install-command.test.ts +23 -1
  61. package/tests/install-layout.test.ts +117 -0
  62. package/tests/list-command.test.ts +76 -0
  63. package/tests/output.test.ts +69 -3
package/src/install.ts CHANGED
@@ -16,18 +16,20 @@ import * as path from 'path'
16
16
  import { unzipSync } from 'fflate'
17
17
  import { detectPackageManager, installDependencies } from 'nypm'
18
18
  import type { MarketClient } from './client.js'
19
+ import type { AssetInstallMetadata } from './contract.js'
20
+ import { readMarketLock, sha256, writeMarketLock } from './market-lock.js'
21
+ import { parsePackageJson, type PackageJson } from './package-json.js'
19
22
  import type { ResolveResult } from './resolve.js'
20
23
 
21
- interface PackageJson {
22
- name?: string
23
- private?: boolean
24
- dependencies?: Record<string, string>
25
- [key: string]: unknown
26
- }
27
-
28
24
  export interface InstallOptions {
29
25
  /** Directory to start project root discovery from (default: cwd) */
30
26
  cwd?: string
27
+ /** Overwrite existing files that differ from the asset zip. */
28
+ force?: boolean
29
+ /** Top-level assets requested by the user, used for package.json assetDependencies. */
30
+ rootRequests?: InstallRootRequest[]
31
+ /** Asset-type install policy from the Market API. */
32
+ installMetadata?: Record<string, AssetInstallMetadata>
31
33
  /** Log progress */
32
34
  onProgress?: (message: string) => void
33
35
  /**
@@ -38,6 +40,13 @@ export interface InstallOptions {
38
40
  runSkillAdd?: (source: string, cwd: string) => Promise<void>
39
41
  }
40
42
 
43
+ export interface InstallRootRequest {
44
+ name: string
45
+ range: string
46
+ saveRange: string
47
+ save: boolean
48
+ }
49
+
41
50
  export interface InstalledAsset {
42
51
  name: string
43
52
  type: string
@@ -51,6 +60,17 @@ export interface InstallResult {
51
60
  npmDependencies: Record<string, string>
52
61
  /** Installed skills, keyed by label, with the source passed to `skills add`. */
53
62
  skillDependencies: Record<string, string>
63
+ warnings: string[]
64
+ }
65
+
66
+ interface DownloadedAsset extends InstalledAsset {
67
+ fileHashes: Record<string, { sha256: string }>
68
+ }
69
+
70
+ interface DownloadResult {
71
+ assets: DownloadedAsset[]
72
+ wrotePackageJson: boolean
73
+ warnings: string[]
54
74
  }
55
75
 
56
76
  export async function install(
@@ -60,11 +80,21 @@ export async function install(
60
80
  ): Promise<InstallResult> {
61
81
  const log = opts.onProgress ?? (() => {})
62
82
  const installRoot = await findInstallRoot(opts.cwd ?? process.cwd())
83
+ const metadata = opts.installMetadata ?? {}
63
84
 
64
- const [assets] = await Promise.all([
65
- downloadAssets(client, resolution, installRoot, log),
66
- installNpmDeps(resolution, installRoot, log),
67
- ])
85
+ const download = await downloadAssets(client, resolution, installRoot, {
86
+ force: opts.force ?? false,
87
+ log,
88
+ })
89
+ await writeInstalledAssetLock(installRoot, download.assets)
90
+ const packageJsonUpdate = await updatePackageJson(resolution, installRoot, {
91
+ rootRequests: opts.rootRequests ?? [],
92
+ installMetadata: metadata,
93
+ packageManagerNeeded: download.wrotePackageJson,
94
+ })
95
+ if (packageJsonUpdate.packageManagerNeeded) {
96
+ await runPackageManagerInstall(installRoot, log)
97
+ }
68
98
 
69
99
  // Skills are installed after asset files land on disk: a skill source may be
70
100
  // a local path pointing at a skill directory shipped inside an installed
@@ -77,9 +107,16 @@ export async function install(
77
107
  )
78
108
 
79
109
  return {
80
- assets,
110
+ assets: download.assets.map((asset) => ({
111
+ name: asset.name,
112
+ type: asset.type,
113
+ version: asset.version,
114
+ description: asset.description,
115
+ files: asset.files,
116
+ })),
81
117
  npmDependencies: resolution.npmDependencies,
82
118
  skillDependencies,
119
+ warnings: download.warnings,
83
120
  }
84
121
  }
85
122
 
@@ -100,16 +137,22 @@ async function downloadAssets(
100
137
  client: MarketClient,
101
138
  resolution: ResolveResult,
102
139
  projectRoot: string,
103
- log: (msg: string) => void,
104
- ): Promise<InstalledAsset[]> {
105
- const installedAssets: InstalledAsset[] = []
140
+ opts: {
141
+ force: boolean
142
+ log: (msg: string) => void
143
+ },
144
+ ): Promise<DownloadResult> {
145
+ const installedAssets: DownloadedAsset[] = []
146
+ const warnings: string[] = []
147
+ let wrotePackageJson = false
106
148
 
107
149
  for (const asset of resolution.assets) {
108
- log(`Downloading ${asset.name}@${asset.version}...`)
150
+ opts.log(`Downloading ${asset.name}@${asset.version}...`)
109
151
 
110
152
  const zip = await client.asset.downloadZip({ name: asset.name, version: asset.version })
111
153
  const files = unzipSync(new Uint8Array(await zip.arrayBuffer()))
112
154
  const installedFiles: string[] = []
155
+ const fileHashes: Record<string, { sha256: string }> = {}
113
156
 
114
157
  for (const [relativePath, content] of Object.entries(files) as [string, Uint8Array][]) {
115
158
  const zipPath = relativePath.replace(/\\/g, '/')
@@ -124,16 +167,31 @@ async function downloadAssets(
124
167
  const normalizedPath = path.posix.normalize(zipPath)
125
168
  if (
126
169
  normalizedPath === '.' ||
127
- normalizedPath === 'README.md' ||
170
+ (normalizedPath === 'README.md' && asset.type !== 'template') ||
128
171
  normalizedPath.endsWith('/')
129
172
  ) {
130
173
  continue
131
174
  }
132
175
 
133
176
  const filePath = path.join(projectRoot, normalizedPath)
134
- await fs.mkdir(path.dirname(filePath), { recursive: true })
135
- await fs.writeFile(filePath, content)
177
+ const existing = await maybeReadFile(filePath)
178
+ if (existing && !bytesEqual(existing, content) && !opts.force) {
179
+ warnings.push(
180
+ `Skipped ${normalizedPath} from ${asset.name}@${asset.version}; file already exists. Re-run with --force to overwrite.`,
181
+ )
182
+ continue
183
+ }
184
+
185
+ if (!existing || !bytesEqual(existing, content)) {
186
+ await fs.mkdir(path.dirname(filePath), { recursive: true })
187
+ await fs.writeFile(filePath, content)
188
+ if (normalizedPath === 'package.json') {
189
+ wrotePackageJson = true
190
+ }
191
+ }
192
+
136
193
  installedFiles.push(normalizedPath)
194
+ fileHashes[normalizedPath] = { sha256: sha256(content) }
137
195
  }
138
196
 
139
197
  installedAssets.push({
@@ -142,12 +200,13 @@ async function downloadAssets(
142
200
  version: asset.version,
143
201
  description: asset.description,
144
202
  files: installedFiles,
203
+ fileHashes,
145
204
  })
146
205
 
147
- log(`Downloaded ${installedFiles.length} files.`)
206
+ opts.log(`Downloaded ${installedFiles.length} files.`)
148
207
  }
149
208
 
150
- return installedAssets
209
+ return { assets: installedAssets, wrotePackageJson, warnings }
151
210
  }
152
211
 
153
212
  async function isFile(file: string): Promise<boolean> {
@@ -158,26 +217,60 @@ async function isFile(file: string): Promise<boolean> {
158
217
  }
159
218
  }
160
219
 
161
- async function installNpmDeps(
220
+ async function maybeReadFile(file: string): Promise<Uint8Array | null> {
221
+ try {
222
+ return await fs.readFile(file)
223
+ } catch (error) {
224
+ if (isMissingFile(error)) return null
225
+ throw error
226
+ }
227
+ }
228
+
229
+ function isMissingFile(error: unknown): boolean {
230
+ return error instanceof Error && 'code' in error && error.code === 'ENOENT'
231
+ }
232
+
233
+ async function updatePackageJson(
162
234
  resolution: ResolveResult,
163
235
  projectRoot: string,
164
- log: (msg: string) => void,
165
- ): Promise<void> {
166
- const deps = resolution.npmDependencies
167
- if (Object.keys(deps).length === 0) return
168
-
236
+ opts: {
237
+ rootRequests: InstallRootRequest[]
238
+ installMetadata: Record<string, AssetInstallMetadata>
239
+ packageManagerNeeded: boolean
240
+ },
241
+ ): Promise<{ packageManagerNeeded: boolean }> {
169
242
  const pkgPath = path.join(projectRoot, 'package.json')
170
- // Only synthesize a package.json when none exists. A malformed existing file
171
- // is a real error that must surface — never silently overwrite it.
172
- const pkg: PackageJson = (await isFile(pkgPath))
173
- ? (JSON.parse(await fs.readFile(pkgPath, 'utf-8')) as PackageJson)
174
- : { name: 'my-project', private: true, dependencies: {} }
243
+ const pkg = (await isFile(pkgPath))
244
+ ? parsePackageJson(await fs.readFile(pkgPath, 'utf-8'), pkgPath)
245
+ : defaultPackageJson()
246
+
247
+ let changed = false
248
+ let packageManagerNeeded = opts.packageManagerNeeded
175
249
 
176
- pkg.dependencies = { ...pkg.dependencies, ...deps }
177
- await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
250
+ const npmChanged = mergeRecord(pkg, 'dependencies', resolution.npmDependencies)
251
+ if (npmChanged) {
252
+ changed = true
253
+ packageManagerNeeded = true
254
+ }
178
255
 
179
- log(`Installing npm dependencies: ${Object.keys(deps).join(', ')}`)
256
+ const assetDependencies = assetDependenciesToSave(
257
+ resolution,
258
+ opts.rootRequests,
259
+ opts.installMetadata,
260
+ )
261
+ if (mergeRecord(pkg, 'assetDependencies', assetDependencies)) {
262
+ changed = true
263
+ }
180
264
 
265
+ if (changed) {
266
+ await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
267
+ }
268
+
269
+ return { packageManagerNeeded }
270
+ }
271
+
272
+ async function runPackageManagerInstall(projectRoot: string, log: (msg: string) => void) {
273
+ log('Installing npm dependencies...')
181
274
  const pm = await detectPackageManager(projectRoot)
182
275
  const pmName = pm?.name ?? 'npm'
183
276
 
@@ -186,6 +279,75 @@ async function installNpmDeps(
186
279
  log('npm dependencies installed.')
187
280
  }
188
281
 
282
+ async function writeInstalledAssetLock(
283
+ projectRoot: string,
284
+ assets: DownloadedAsset[],
285
+ ): Promise<void> {
286
+ const lock = await readMarketLock(projectRoot)
287
+ for (const asset of assets) {
288
+ lock.assets[asset.name] = {
289
+ version: asset.version,
290
+ type: asset.type,
291
+ files: asset.fileHashes,
292
+ }
293
+ }
294
+ await writeMarketLock(projectRoot, lock)
295
+ }
296
+
297
+ function defaultPackageJson(): PackageJson {
298
+ return { name: 'my-project', private: true, dependencies: {} }
299
+ }
300
+
301
+ function mergeRecord(
302
+ pkg: PackageJson,
303
+ field: 'dependencies' | 'assetDependencies',
304
+ values: Record<string, string>,
305
+ ): boolean {
306
+ const entries = Object.entries(values)
307
+ if (entries.length === 0) return false
308
+
309
+ const current = pkg[field] ?? {}
310
+ let changed = false
311
+
312
+ for (const [name, range] of entries) {
313
+ if (current[name] === range) continue
314
+ current[name] = range
315
+ changed = true
316
+ }
317
+
318
+ pkg[field] = current
319
+ return changed
320
+ }
321
+
322
+ function assetDependenciesToSave(
323
+ resolution: ResolveResult,
324
+ rootRequests: InstallRootRequest[],
325
+ metadata: Record<string, AssetInstallMetadata>,
326
+ ): Record<string, string> {
327
+ const resolvedByName = new Map(resolution.assets.map((asset) => [asset.name, asset]))
328
+ const out: Record<string, string> = {}
329
+
330
+ for (const request of rootRequests) {
331
+ if (!request.save) continue
332
+
333
+ const asset = resolvedByName.get(request.name)
334
+ if (!asset) continue
335
+
336
+ if ((metadata[asset.type]?.saveOnInstall ?? true) === false) continue
337
+ out[asset.name] = request.saveRange
338
+ }
339
+
340
+ return out
341
+ }
342
+
343
+ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
344
+ if (a.length !== b.length) return false
345
+ for (let i = 0; i < a.length; i += 1) {
346
+ if (a[i] !== b[i]) return false
347
+ }
348
+ return true
349
+ }
350
+
189
351
  async function installSkills(
190
352
  resolution: ResolveResult,
191
353
  projectRoot: string,
@@ -0,0 +1,65 @@
1
+ import { createHash } from 'crypto'
2
+ import * as fs from 'fs/promises'
3
+ import * as path from 'path'
4
+ import { z } from 'zod'
5
+
6
+ export const MARKET_LOCK_PATH = '.drawcall/market-lock.json'
7
+
8
+ export interface MarketLockAsset {
9
+ version: string
10
+ type: string
11
+ files: Record<string, { sha256: string }>
12
+ }
13
+
14
+ export interface MarketLock {
15
+ version: 1
16
+ assets: Record<string, MarketLockAsset>
17
+ }
18
+
19
+ const marketLockSchema = z.object({
20
+ version: z.literal(1),
21
+ assets: z.record(
22
+ z.string(),
23
+ z.object({
24
+ version: z.string(),
25
+ type: z.string(),
26
+ files: z.record(
27
+ z.string(),
28
+ z.object({
29
+ sha256: z.string(),
30
+ }),
31
+ ),
32
+ }),
33
+ ),
34
+ })
35
+
36
+ export async function readMarketLock(projectRoot: string): Promise<MarketLock> {
37
+ const lockPath = path.join(projectRoot, MARKET_LOCK_PATH)
38
+ try {
39
+ return marketLockSchema.parse(JSON.parse(await fs.readFile(lockPath, 'utf-8')))
40
+ } catch (error) {
41
+ if (isMissingFile(error)) return emptyMarketLock()
42
+ throw error
43
+ }
44
+ }
45
+
46
+ export async function writeMarketLock(projectRoot: string, lock: MarketLock): Promise<void> {
47
+ const lockPath = path.join(projectRoot, MARKET_LOCK_PATH)
48
+ await fs.mkdir(path.dirname(lockPath), { recursive: true })
49
+ await fs.writeFile(lockPath, JSON.stringify(lock, null, 2) + '\n')
50
+ }
51
+
52
+ export function emptyMarketLock(): MarketLock {
53
+ return {
54
+ version: 1,
55
+ assets: {},
56
+ }
57
+ }
58
+
59
+ export function sha256(bytes: Uint8Array): string {
60
+ return createHash('sha256').update(bytes).digest('hex')
61
+ }
62
+
63
+ function isMissingFile(error: unknown): boolean {
64
+ return error instanceof Error && 'code' in error && error.code === 'ENOENT'
65
+ }
package/src/output.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { AssetSearchResult } from './contract.js'
1
+ import type { AssetInstallMetadata, AssetSearchResult } from './contract.js'
2
+ import type { ListInstalledAssetsResult } from './commands/list.js'
2
3
  import type { InstallResult } from './install.js'
3
4
 
4
5
  export function assetVersionRef(name: string, version?: string): string {
@@ -33,22 +34,19 @@ export function assetSearchResultLine(item: AssetSearchResult): string {
33
34
 
34
35
  export function installResult(
35
36
  result: InstallResult,
36
- postInstallMessages: Record<string, string> = {},
37
+ installMetadata: Record<string, AssetInstallMetadata> = {},
37
38
  ): string {
38
39
  const npmDependencies = Object.entries(result.npmDependencies)
39
40
 
40
41
  const lines = ['Installed:']
41
42
 
42
43
  for (const asset of result.assets) {
43
- const files = unique(asset.files)
44
- lines.push(`- ${assetVersionRef(asset.name, asset.version)} (${asset.type})`)
44
+ lines.push(assetLine(asset))
45
45
  if (asset.description) {
46
46
  lines.push(...block(' description: ', asset.description))
47
47
  }
48
- if (files.length > 0) {
49
- lines.push(' files:', ...indent(fileTree(files), ' '))
50
- }
51
- const message = postInstallMessages[asset.type]
48
+ lines.push(...assetFileLines(asset.files))
49
+ const message = installMetadata[asset.type]?.installMessage
52
50
  if (message) {
53
51
  lines.push(...block(' note: ', message))
54
52
  }
@@ -71,9 +69,39 @@ export function installResult(
71
69
  )
72
70
  }
73
71
 
72
+ if (result.warnings.length > 0) {
73
+ lines.push('Warnings:', ...result.warnings.map((warning) => `- ${warning}`))
74
+ }
75
+
76
+ return lines.join('\n')
77
+ }
78
+
79
+ export function listResult(result: ListInstalledAssetsResult): string {
80
+ if (result.assets.length === 0) {
81
+ return 'No installed assets found.'
82
+ }
83
+
84
+ const lines = [`Installed assets: ${result.assets.length}`]
85
+ for (const asset of result.assets) {
86
+ lines.push(assetLine(asset), ...assetFileLines(asset.files))
87
+ }
88
+
74
89
  return lines.join('\n')
75
90
  }
76
91
 
92
+ function assetLine(asset: { name: string; version: string; type: string }): string {
93
+ return `- ${assetVersionRef(asset.name, asset.version)} (${asset.type})`
94
+ }
95
+
96
+ function assetFileLines(paths: string[]): string[] {
97
+ const lines: string[] = []
98
+ const files = unique(paths)
99
+ if (files.length > 0) {
100
+ lines.push(' files:', ...indent(fileTree(files), ' '))
101
+ }
102
+ return lines
103
+ }
104
+
77
105
  function block(label: string, message: string, width = 68): string[] {
78
106
  const hang = ' '.repeat(label.length)
79
107
  const words = normalizeInline(message).split(' ').filter(Boolean)
@@ -0,0 +1,37 @@
1
+ import { z } from 'zod'
2
+
3
+ export interface PackageJson {
4
+ name?: string
5
+ private?: boolean
6
+ dependencies?: Record<string, string>
7
+ assetDependencies?: Record<string, string>
8
+ [key: string]: unknown
9
+ }
10
+
11
+ const stringRecordSchema = z.record(z.string(), z.string())
12
+
13
+ export function packageJsonAssetDependenciesFromFiles(
14
+ files: Record<string, Uint8Array>,
15
+ ): Record<string, string> {
16
+ const bytes = files['package.json']
17
+ if (!bytes) return {}
18
+
19
+ return packageJsonAssetDependencies(new TextDecoder().decode(bytes), 'package.json')
20
+ }
21
+
22
+ export function packageJsonAssetDependencies(json: string, source: string): Record<string, string> {
23
+ const parsed = parsePackageJson(json, source)
24
+ const dependencies = parsed.assetDependencies
25
+ if (dependencies === undefined) return {}
26
+
27
+ return stringRecordSchema.parse(dependencies)
28
+ }
29
+
30
+ export function parsePackageJson(json: string, source: string): PackageJson {
31
+ const parsed = JSON.parse(json) as unknown
32
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
33
+ throw new Error(`${source} must contain a JSON object`)
34
+ }
35
+
36
+ return parsed as PackageJson
37
+ }
package/src/skill.ts CHANGED
@@ -12,20 +12,22 @@ Use the \`market\` CLI. Keep commands short and read the summary lines.
12
12
  \`\`\`sh
13
13
  market search "wooden chair" --type model --limit 3
14
14
  market install wooden-chair --cwd "$PWD"
15
+ market list --cwd "$PWD"
15
16
  market preview wooden-chair --out /tmp/wooden-chair.png
16
17
  \`\`\`
17
18
 
18
19
  ## Workflow
19
20
 
20
- 1. Search first unless the user already gave an exact asset name. \`search\` requires \`--type\`; use \`model\` unless the user names another supported type: \`humanoid-model\`, \`texture\`, \`humanoid-animation\`, \`template\`, \`sound-effect\`, \`background-music\`, \`environment\`, or \`flipbook\`.
21
- 2. Use \`--limit 1\` for lookup, \`--limit 3\` for choice. Search caps at 5 and prints full descriptions.
22
- 3. \`install\` takes one or more exact asset names (optionally \`name@range\`); it does not search or generate. Find names with \`search\` first. No \`--type\` is needed — asset names are unique.
23
- 4. \`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.
24
- 5. Use \`--unapproved\` only when the user asks for unapproved/private/admin assets. Do not install unapproved assets without explicit acceptance.
25
- 6. \`generate --type <type> "<prompt>"\` creates a new asset; it requires login and a type that supports generation. No asset type currently supports generation.
26
- 7. 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\`. 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\`.
27
- 8. 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.
28
- 9. 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.
21
+ 1. In an existing repo, run \`list --cwd "$PWD"\` first to see installed local assets from \`.drawcall/market-lock.json\`. Use the listed names with \`preview <name>\` when you want preview images.
22
+ 2. Search first unless the user already gave an exact asset name. \`search\` requires \`--type\`; use \`model\` unless the user names another supported type: \`humanoid-model\`, \`texture\`, \`humanoid-animation\`, \`template\`, \`sound-effect\`, \`background-music\`, \`environment\`, or \`flipbook\`.
23
+ 3. Use \`--limit 1\` for lookup, \`--limit 3\` for choice. Search caps at 5 and prints full descriptions.
24
+ 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
+ 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
+ 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 a new asset; it requires login and a type that supports generation. No asset type currently supports generation.
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\`.
29
+ 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
+ 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.
29
31
 
30
32
  ## Humanoid animations
31
33
 
@@ -48,10 +50,19 @@ Installed:
48
50
  └─ wooden-chair.glb
49
51
  - three@^0.178.0 (npm)
50
52
  - web-design ← https://github.com/vercel-labs/agent-skills/tree/main/skills/web-design-guidelines (skill)
53
+ Installed assets: 1
54
+ - wooden-chair@1.0.0 (model)
55
+ files:
56
+ public/model
57
+ └─ wooden-chair.glb
51
58
  Saved preview for wooden-chair@1.0.0: /tmp/wooden-chair.png
52
59
  \`\`\`
53
60
 
54
61
  Assets may also declare \`skill\` dependencies, installed for you via the \`skills\` CLI (\`skills add\`) during \`install\`. Sources are either a GitHub/git ref or a local path to a skill directory shipped inside the asset. This requires \`npx\` to be available.
55
62
 
63
+ Installed non-template assets are saved to \`package.json.assetDependencies\`; templates are scaffolds and are not saved as project asset dependencies. Exact installed versions and file hashes are recorded in \`.drawcall/market-lock.json\`.
64
+
65
+ \`list\` is offline: it reads \`.drawcall/market-lock.json\` from the nearest package root and prints exact installed names, versions, types, and installed file paths.
66
+
56
67
  If search returns no results, try one broader noun phrase. If a command returns \`Error: Not logged in...\`, ask before running \`market login\`.
57
68
  `
@@ -31,7 +31,29 @@ test('resolveArg keeps explicit versioned refs exact when the asset exists', asy
31
31
 
32
32
  const request = await resolveArg(client, 'fresh-sky@1.2.3', false)
33
33
 
34
- assert.deepEqual(request, { name: 'fresh-sky', range: '1.2.3' })
34
+ assert.deepEqual(request, {
35
+ name: 'fresh-sky',
36
+ range: '1.2.3',
37
+ saveRange: '1.2.3',
38
+ save: true,
39
+ })
40
+ })
41
+
42
+ test('resolveArg saves an implicit range from the selected latest version', async () => {
43
+ const client = {
44
+ asset: {
45
+ exact: async () => ({ name: 'fresh-sky', latestVersion: '1.2.3', approved: true }),
46
+ },
47
+ } as never
48
+
49
+ const request = await resolveArg(client, 'fresh-sky', false)
50
+
51
+ assert.deepEqual(request, {
52
+ name: 'fresh-sky',
53
+ range: '*',
54
+ saveRange: '^1.2.3',
55
+ save: true,
56
+ })
35
57
  })
36
58
 
37
59
  test('resolveArg errors when the asset does not exist instead of deferring', async () => {