@drawcall/market 0.1.26 → 0.1.28

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 (64) hide show
  1. package/dist/asset-implementation.d.ts +1 -0
  2. package/dist/asset-implementation.d.ts.map +1 -1
  3. package/dist/cli.js +23 -15
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/install.d.ts +8 -4
  6. package/dist/commands/install.d.ts.map +1 -1
  7. package/dist/commands/install.js +24 -59
  8. package/dist/commands/install.js.map +1 -1
  9. package/dist/commands/preview.d.ts +0 -2
  10. package/dist/commands/preview.d.ts.map +1 -1
  11. package/dist/commands/preview.js +4 -6
  12. package/dist/commands/preview.js.map +1 -1
  13. package/dist/commands/upload.d.ts +18 -0
  14. package/dist/commands/upload.d.ts.map +1 -1
  15. package/dist/commands/upload.js +67 -3
  16. package/dist/commands/upload.js.map +1 -1
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/config.js +6 -0
  19. package/dist/config.js.map +1 -1
  20. package/dist/contract.d.ts +3 -0
  21. package/dist/contract.d.ts.map +1 -1
  22. package/dist/contract.js.map +1 -1
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +1 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/install.d.ts +11 -2
  28. package/dist/install.d.ts.map +1 -1
  29. package/dist/install.js +69 -19
  30. package/dist/install.js.map +1 -1
  31. package/dist/output.d.ts.map +1 -1
  32. package/dist/output.js +6 -0
  33. package/dist/output.js.map +1 -1
  34. package/dist/resolve.d.ts +6 -3
  35. package/dist/resolve.d.ts.map +1 -1
  36. package/dist/resolve.js +66 -20
  37. package/dist/resolve.js.map +1 -1
  38. package/dist/schemas.d.ts +2 -0
  39. package/dist/schemas.d.ts.map +1 -1
  40. package/dist/schemas.js +5 -0
  41. package/dist/schemas.js.map +1 -1
  42. package/dist/skill.d.ts +1 -1
  43. package/dist/skill.d.ts.map +1 -1
  44. package/dist/skill.js +19 -12
  45. package/dist/skill.js.map +1 -1
  46. package/package.json +1 -1
  47. package/src/asset-implementation.ts +1 -0
  48. package/src/cli.ts +44 -33
  49. package/src/commands/install.ts +30 -77
  50. package/src/commands/preview.ts +5 -10
  51. package/src/commands/upload.ts +79 -3
  52. package/src/config.ts +6 -0
  53. package/src/contract.ts +2 -0
  54. package/src/index.ts +1 -0
  55. package/src/install.ts +95 -19
  56. package/src/output.ts +9 -0
  57. package/src/resolve.ts +81 -22
  58. package/src/schemas.ts +6 -0
  59. package/src/skill.ts +19 -12
  60. package/tests/install-command.test.ts +13 -46
  61. package/tests/install-layout.test.ts +60 -2
  62. package/tests/output.test.ts +18 -0
  63. package/tests/resolve-skills.test.ts +87 -0
  64. package/tests/upload-deps.test.ts +44 -0
package/src/install.ts CHANGED
@@ -4,11 +4,13 @@
4
4
  * 1. Downloads public asset files into ./public/... via the oRPC client.
5
5
  * 2. Merges npm dependencies into package.json.
6
6
  * 3. Runs the package manager to install npm deps.
7
+ * 4. Installs skill dependencies via the `skills` CLI (`skills add <source>`).
7
8
  *
8
- * NOTE: This module uses Node.js APIs (fs, path, nypm) and is only
9
- * used by the CLI binary; it is NOT exported from the package index.
9
+ * NOTE: This module uses Node.js APIs (fs, path, nypm, child_process) and is
10
+ * only used by the CLI binary; it is NOT exported from the package index.
10
11
  */
11
12
 
13
+ import { execFile } from 'child_process'
12
14
  import * as fs from 'fs/promises'
13
15
  import * as path from 'path'
14
16
  import { unzipSync } from 'fflate'
@@ -28,6 +30,12 @@ export interface InstallOptions {
28
30
  cwd?: string
29
31
  /** Log progress */
30
32
  onProgress?: (message: string) => void
33
+ /**
34
+ * Override how a single resolved skill source is installed. The default
35
+ * shells out to `npx skills add <source> -y`. Exposed for tests and for
36
+ * callers that want to drive a different skills runner.
37
+ */
38
+ runSkillAdd?: (source: string, cwd: string) => Promise<void>
31
39
  }
32
40
 
33
41
  export interface InstalledAsset {
@@ -39,6 +47,8 @@ export interface InstalledAsset {
39
47
  export interface InstallResult {
40
48
  assets: InstalledAsset[]
41
49
  npmDependencies: Record<string, string>
50
+ /** Installed skills, keyed by label, with the source passed to `skills add`. */
51
+ skillDependencies: Record<string, string>
42
52
  }
43
53
 
44
54
  export async function install(
@@ -54,28 +64,34 @@ export async function install(
54
64
  installNpmDeps(resolution, installRoot, log),
55
65
  ])
56
66
 
67
+ // Skills are installed after asset files land on disk: a skill source may be
68
+ // a local path pointing at a skill directory shipped inside an installed
69
+ // asset, which only exists once downloadAssets has completed.
70
+ const skillDependencies = await installSkills(
71
+ resolution,
72
+ installRoot,
73
+ log,
74
+ opts.runSkillAdd ?? defaultRunSkillAdd,
75
+ )
76
+
57
77
  return {
58
78
  assets,
59
79
  npmDependencies: resolution.npmDependencies,
80
+ skillDependencies,
60
81
  }
61
82
  }
62
83
 
63
84
  export async function findInstallRoot(cwd: string = process.cwd()): Promise<string> {
64
85
  const start = path.resolve(cwd)
65
- let packageRoot: string | null = null
66
86
 
67
- let dir = start
68
- while (true) {
87
+ for (let dir = start; ; dir = path.dirname(dir)) {
69
88
  if (await isFile(path.join(dir, 'package.json'))) {
70
- packageRoot = dir
89
+ return dir
90
+ }
91
+ if (path.dirname(dir) === dir) {
92
+ return start
71
93
  }
72
-
73
- const parent = path.dirname(dir)
74
- if (parent === dir) break
75
- dir = parent
76
94
  }
77
-
78
- return packageRoot ?? start
79
95
  }
80
96
 
81
97
  async function downloadAssets(
@@ -147,22 +163,82 @@ async function installNpmDeps(
147
163
  if (Object.keys(deps).length === 0) return
148
164
 
149
165
  const pkgPath = path.join(projectRoot, 'package.json')
150
- let pkg: PackageJson
151
- try {
152
- pkg = JSON.parse(await fs.readFile(pkgPath, 'utf-8')) as PackageJson
153
- } catch {
154
- pkg = { name: 'my-project', private: true, dependencies: {} }
155
- }
166
+ // Only synthesize a package.json when none exists. A malformed existing file
167
+ // is a real error that must surface — never silently overwrite it.
168
+ const pkg: PackageJson = (await isFile(pkgPath))
169
+ ? (JSON.parse(await fs.readFile(pkgPath, 'utf-8')) as PackageJson)
170
+ : { name: 'my-project', private: true, dependencies: {} }
156
171
 
157
172
  pkg.dependencies = { ...pkg.dependencies, ...deps }
158
173
  await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
159
174
 
160
175
  log(`Installing npm dependencies: ${Object.keys(deps).join(', ')}`)
161
176
 
162
- const pm = await detectPackageManager(projectRoot).catch(() => null)
177
+ const pm = await detectPackageManager(projectRoot)
163
178
  const pmName = pm?.name ?? 'npm'
164
179
 
165
180
  log(`Using ${pmName}...`)
166
181
  await installDependencies({ cwd: projectRoot, packageManager: { name: pmName, command: pmName } })
167
182
  log('npm dependencies installed.')
168
183
  }
184
+
185
+ async function installSkills(
186
+ resolution: ResolveResult,
187
+ projectRoot: string,
188
+ log: (msg: string) => void,
189
+ runSkillAdd: (source: string, cwd: string) => Promise<void>,
190
+ ): Promise<Record<string, string>> {
191
+ const skills = resolution.skillDependencies ?? {}
192
+ const entries = Object.entries(skills)
193
+ if (entries.length === 0) return {}
194
+
195
+ for (const [label, source] of entries) {
196
+ const resolvedSource = resolveSkillSource(source, projectRoot)
197
+ log(`Installing skill ${label} (${source})...`)
198
+ await runSkillAdd(resolvedSource, projectRoot)
199
+ }
200
+
201
+ log(`Skills installed: ${entries.map(([label]) => label).join(', ')}`)
202
+ return skills
203
+ }
204
+
205
+ /**
206
+ * Resolve a skill source to the argument passed to `skills add`. Local paths
207
+ * (`./`, `../`, `.`, `..`, or absolute) are resolved against the install root
208
+ * so they point at a skill directory shipped inside an installed asset; every
209
+ * other form (GitHub shorthand, git/HTTP(S) URL, `tree/<branch>/<subpath>`) is
210
+ * a remote ref and is passed through untouched.
211
+ */
212
+ function resolveSkillSource(source: string, projectRoot: string): string {
213
+ return isLocalSkillPath(source) ? path.resolve(projectRoot, source) : source
214
+ }
215
+
216
+ function isLocalSkillPath(source: string): boolean {
217
+ return (
218
+ path.isAbsolute(source) ||
219
+ source === '.' ||
220
+ source === '..' ||
221
+ source.startsWith('./') ||
222
+ source.startsWith('../') ||
223
+ /^[a-zA-Z]:[/\\]/.test(source)
224
+ )
225
+ }
226
+
227
+ /**
228
+ * Install one skill via `npx skills add <source> -y`. When run inside a coding
229
+ * agent the `skills` CLI auto-detects the agent and installs non-interactively;
230
+ * `-y` keeps it non-interactive everywhere else. npx resolves (and caches) the
231
+ * `skills` package on first use.
232
+ */
233
+ function defaultRunSkillAdd(source: string, cwd: string): Promise<void> {
234
+ return new Promise((resolve, reject) => {
235
+ execFile('npx', ['--yes', 'skills', 'add', source, '-y'], { cwd }, (error, _stdout, stderr) => {
236
+ if (error) {
237
+ const detail = stderr.trim() || error.message
238
+ reject(new Error(`Failed to install skill "${source}": ${detail}`))
239
+ return
240
+ }
241
+ resolve()
242
+ })
243
+ })
244
+ }
package/src/output.ts CHANGED
@@ -57,6 +57,15 @@ export function installResult(result: InstallResult): string {
57
57
  )
58
58
  }
59
59
 
60
+ const skillDependencies = Object.entries(result.skillDependencies ?? {})
61
+ if (skillDependencies.length > 0) {
62
+ lines.push(
63
+ ...skillDependencies
64
+ .sort(([a], [b]) => a.localeCompare(b))
65
+ .map(([label, source]) => `- ${label} ← ${source} (skill)`),
66
+ )
67
+ }
68
+
60
69
  return lines.join('\n')
61
70
  }
62
71
 
package/src/resolve.ts CHANGED
@@ -2,9 +2,10 @@
2
2
  * Dependency resolver for market assets.
3
3
  *
4
4
  * 1. Collects the target assets and all transitive asset dependencies.
5
- * 2. For each asset, finds a version that satisfies ALL constraints from
6
- * every dependent.
7
- * 3. Merges npm dependency ranges across all resolved assets and checks
5
+ * 2. For each asset, takes the latest published version and verifies it
6
+ * satisfies ALL constraints from every dependent. (Only the latest
7
+ * version is resolved; older versions are not considered.)
8
+ * 3. Intersects npm dependency ranges across all resolved assets and checks
8
9
  * that they are compatible.
9
10
  */
10
11
 
@@ -17,11 +18,13 @@ export interface ResolvedAsset {
17
18
  version: string
18
19
  npmDependencies: Record<string, string>
19
20
  assetDependencies: Record<string, string>
21
+ skillDependencies: Record<string, string>
20
22
  }
21
23
 
22
24
  export interface ResolveResult {
23
25
  assets: ResolvedAsset[]
24
26
  npmDependencies: Record<string, string>
27
+ skillDependencies: Record<string, string>
25
28
  }
26
29
 
27
30
  interface AssetRequest {
@@ -58,30 +61,34 @@ export async function resolve(
58
61
  }
59
62
 
60
63
  if (!opts.includeUnapproved && !meta.approved) {
61
- throw new ResolutionError(
62
- `Asset "${assetName}" has no ${opts.includeUnapproved ? '' : 'approved '}versions.`,
63
- )
64
+ throw new ResolutionError(`Asset "${assetName}" has no approved versions.`)
64
65
  }
65
66
 
67
+ // The market publishes immutable versions and resolution targets the
68
+ // latest published version of each asset. A range that excludes the
69
+ // latest version is therefore unsatisfiable (older versions are not
70
+ // resolved), which this message makes explicit.
66
71
  const assetConstraints = constraints.get(assetName) ?? []
67
72
  if (!assetConstraints.every((c) => semver.satisfies(meta.latestVersion, c.range))) {
68
73
  const constraintDesc = assetConstraints
69
74
  .map((c) => ` ${c.range} (from ${c.from})`)
70
75
  .join('\n')
71
76
  throw new ResolutionError(
72
- `No version of "${assetName}" satisfies all constraints:\n${constraintDesc}\n` +
73
- `Available${opts.includeUnapproved ? '' : ' approved'}: ${meta.latestVersion}`,
77
+ `The latest${opts.includeUnapproved ? '' : ' approved'} version of "${assetName}" ` +
78
+ `(${meta.latestVersion}) does not satisfy all constraints:\n${constraintDesc}`,
74
79
  )
75
80
  }
76
81
 
77
- const npmDeps: Record<string, string> = JSON.parse(meta.npmDependencies)
78
- const assetDeps: Record<string, string> = JSON.parse(meta.assetDependencies)
82
+ const npmDeps = parseDependencies(meta.npmDependencies, assetName, 'npm')
83
+ const assetDeps = parseDependencies(meta.assetDependencies, assetName, 'asset')
84
+ const skillDeps = parseDependencies(meta.skillDependencies, assetName, 'skill')
79
85
 
80
86
  resolved.set(assetName, {
81
87
  name: assetName,
82
88
  version: meta.latestVersion,
83
89
  npmDependencies: npmDeps,
84
90
  assetDependencies: assetDeps,
91
+ skillDependencies: skillDeps,
85
92
  })
86
93
 
87
94
  for (const [depName, depRange] of Object.entries(assetDeps)) {
@@ -106,15 +113,61 @@ export async function resolve(
106
113
  }
107
114
  }
108
115
 
109
- // Merge npm dependencies across all resolved assets
116
+ // Merge npm and skill dependencies across all resolved assets
110
117
  const mergedNpm = mergeNpmDependencies(Array.from(resolved.values()))
118
+ const mergedSkills = mergeSkillDependencies(Array.from(resolved.values()))
111
119
 
112
120
  return {
113
121
  assets: Array.from(resolved.values()),
114
122
  npmDependencies: mergedNpm,
123
+ skillDependencies: mergedSkills,
115
124
  }
116
125
  }
117
126
 
127
+ /**
128
+ * Merge skill dependencies from all resolved assets. Skills are not versioned
129
+ * by the resolver — each entry is a label mapped to a `skills add` source — so
130
+ * the merge is a simple union. The same label declared by two assets must point
131
+ * at the same source; otherwise installing one would silently shadow the other.
132
+ */
133
+ function mergeSkillDependencies(assets: ResolvedAsset[]): Record<string, string> {
134
+ const merged: Record<string, string> = {}
135
+ const origin: Record<string, string> = {}
136
+
137
+ for (const asset of assets) {
138
+ const from = `${asset.name}@${asset.version}`
139
+ for (const [label, source] of Object.entries(asset.skillDependencies)) {
140
+ if (label in merged && merged[label] !== source) {
141
+ throw new ResolutionError(
142
+ `skill dependency conflict for "${label}":\n` +
143
+ ` ${merged[label]} (from ${origin[label]})\n` +
144
+ ` ${source} (from ${from})`,
145
+ )
146
+ }
147
+ merged[label] = source
148
+ origin[label] = from
149
+ }
150
+ }
151
+
152
+ return merged
153
+ }
154
+
155
+ function parseDependencies(
156
+ value: string,
157
+ assetName: string,
158
+ kind: 'npm' | 'asset' | 'skill',
159
+ ): Record<string, string> {
160
+ try {
161
+ const parsed = JSON.parse(value || '{}') as unknown
162
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
163
+ return parsed as Record<string, string>
164
+ }
165
+ } catch {
166
+ // fall through to a clear error below
167
+ }
168
+ throw new ResolutionError(`Asset "${assetName}" has malformed ${kind} dependency metadata.`)
169
+ }
170
+
118
171
  function addConstraint(
119
172
  constraints: Map<string, { range: string; from: string }[]>,
120
173
  name: string,
@@ -151,7 +204,8 @@ async function fetchMeta(
151
204
  /**
152
205
  * Merge npm dependency ranges from all resolved assets.
153
206
  * For each package, check that all declared ranges are compatible
154
- * (using semver.intersects). Return the narrowest range.
207
+ * (using semver.intersects), then return the single declared range that is a
208
+ * subset of every other — the true intersection.
155
209
  */
156
210
  function mergeNpmDependencies(assets: ResolvedAsset[]): Record<string, string> {
157
211
  // Collect all ranges per package
@@ -181,18 +235,23 @@ function mergeNpmDependencies(assets: ResolvedAsset[]): Record<string, string> {
181
235
  }
182
236
  }
183
237
 
184
- // Use the narrowest (most constrained) range.
185
- // Simple heuristic: pick the range with the highest minimum version.
186
- let narrowest = ranges[0].range
187
- for (const r of ranges.slice(1)) {
188
- const minCurrent = semver.minVersion(narrowest)
189
- const minNew = semver.minVersion(r.range)
190
- if (minCurrent && minNew && semver.gt(minNew, minCurrent)) {
191
- narrowest = r.range
192
- }
238
+ // Use the single declared range that is contained in every other declared
239
+ // range the true intersection of all constraints. Picking by "highest
240
+ // minimum version" is unsound: a range with a higher minimum can also have
241
+ // a higher (or open) upper bound, silently dropping another dependent's
242
+ // upper bound. If no single range represents the intersection, it cannot be
243
+ // expressed as one package.json range, so fail loudly.
244
+ const narrowest = ranges.find((candidate) =>
245
+ ranges.every((other) => semver.subset(candidate.range, other.range)),
246
+ )
247
+ if (!narrowest) {
248
+ throw new ResolutionError(
249
+ `npm dependency ranges for "${pkg}" overlap but cannot be combined into a single range:\n` +
250
+ ranges.map((r) => ` ${r.range} (from ${r.from})`).join('\n'),
251
+ )
193
252
  }
194
253
 
195
- merged[pkg] = narrowest
254
+ merged[pkg] = narrowest.range
196
255
  }
197
256
 
198
257
  return merged
package/src/schemas.ts CHANGED
@@ -39,6 +39,11 @@ export const npmDependenciesSchema = z.record(z.string(), z.string()).default({}
39
39
 
40
40
  export const assetDependenciesSchema = z.record(z.string(), z.string()).default({})
41
41
 
42
+ // Maps a skill label to a `skills add` source: a remote ref (owner/repo,
43
+ // git/HTTP URL, tree/<branch>/<subpath>) or a local path to a skill directory
44
+ // shipped inside the asset.
45
+ export const skillDependenciesSchema = z.record(z.string(), z.string()).default({})
46
+
42
47
  export const updateProfileSchema = z.object({
43
48
  name: z.string().min(1).max(100).optional(),
44
49
  image: z.string().url().optional(),
@@ -66,6 +71,7 @@ export const uploadZipSchema = z.object({
66
71
  description: z.string().max(1000).optional(),
67
72
  npmDependencies: npmDependenciesSchema,
68
73
  assetDependencies: assetDependenciesSchema,
74
+ skillDependencies: skillDependenciesSchema,
69
75
  tags: z.array(z.string()).default([]),
70
76
  })
71
77
 
package/src/skill.ts CHANGED
@@ -5,38 +5,45 @@ description: Find, preview, install, generate, and publish Drawcall Market asset
5
5
 
6
6
  # Drawcall Market
7
7
 
8
- Use the \`market\` CLI. Keep commands short and read the summary line plus asset bullets.
8
+ Use the \`market\` CLI. Keep commands short and read the summary lines.
9
9
 
10
10
  ## Quick Start
11
11
 
12
12
  \`\`\`sh
13
13
  market search "wooden chair" --type model --limit 3
14
- market install wooden-chair --type model --cwd "$PWD"
14
+ market install wooden-chair --cwd "$PWD"
15
15
  market preview wooden-chair --out /tmp/wooden-chair.png
16
16
  \`\`\`
17
17
 
18
18
  ## Workflow
19
19
 
20
- 1. Search first unless the user gave an exact asset name.
21
- 2. Search requires \`--type\`; use \`model\` unless the user names another supported type: \`humanoid-model\`, \`texture\`, \`humanoid-animation\`, \`template\`, \`sound-effect\`, \`background-music\`, or \`environment\`.
22
- 3. Use \`--limit 1\` for lookup, \`--limit 3\` for choice. Search caps at 5.
23
- 4. Use \`--verbose\` only when the one-line descriptions are not enough.
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\`, or \`environment\`.
21
+ 2. Use \`--limit 1\` for lookup, \`--limit 3\` for choice. Search caps at 5. Add \`--verbose\` only when the one-line descriptions are not enough.
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
24
  5. Use \`--unapproved\` only when the user asks for unapproved/private/admin assets. Do not install unapproved assets without explicit acceptance.
25
- 6. Preview before installing when visual fit matters: \`market preview <name> --out <file>\`. Preview is not supported for \`humanoid-animation\`, \`template\`, \`sound-effect\`, or \`background-music\`.
26
- 7. Generate only when the user wants a new asset, auth is available, and the requested asset type supports generation. No asset type currently supports generation.
27
- 8. Upload only when publishing is requested.
28
- 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.
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\` (source is a GitHub/git ref 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=vercel-labs/agent-skills\`.
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.
29
28
 
30
29
  ## Output
31
30
 
32
- Commands print concise summaries:
31
+ Commands print concise, line-oriented summaries:
33
32
 
34
33
  \`\`\`text
35
34
  Results: 2/8 query="wooden chair" type=model approval=approved
36
35
  - wooden-chair@1.0.0 | model | approved | Low-poly wooden chair
37
- Installed 1 asset: wooden-chair@1.0.0
36
+ Installed:
37
+ - wooden-chair@1.0.0 (asset)
38
+ files:
39
+ public/model
40
+ └─ wooden-chair.glb
41
+ - three@^0.178.0 (npm)
42
+ - web-design-guidelines ← vercel-labs/agent-skills/tree/main/skills/web-design-guidelines (skill)
38
43
  Saved preview for wooden-chair@1.0.0: /tmp/wooden-chair.png
39
44
  \`\`\`
40
45
 
46
+ 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.
47
+
41
48
  If search returns no results, try one broader noun phrase. If a command returns \`Error: Not logged in...\`, ask before running \`market login\`.
42
49
  `
@@ -2,17 +2,10 @@ import assert from 'node:assert/strict'
2
2
  import test from 'node:test'
3
3
  import { resolveArg } from '../src/commands/install.js'
4
4
 
5
- test('resolveArg reports unapproved explicit assets instead of falling through to generation', async () => {
6
- let searchCalls = 0
5
+ test('resolveArg reports unapproved explicit assets clearly', async () => {
7
6
  const client = {
8
7
  asset: {
9
- exact: async ({
10
- includeUnapproved,
11
- }: {
12
- name: string
13
- type?: string
14
- includeUnapproved: boolean
15
- }) =>
8
+ exact: async ({ includeUnapproved }: { name: string; includeUnapproved: boolean }) =>
16
9
  includeUnapproved
17
10
  ? {
18
11
  name: 'qwantani-moon-noon-puresky',
@@ -20,59 +13,33 @@ test('resolveArg reports unapproved explicit assets instead of falling through t
20
13
  approved: false,
21
14
  }
22
15
  : null,
23
- search: async () => {
24
- searchCalls += 1
25
- return { items: [], total: 0, page: 1, limit: 1, totalPages: 0 }
26
- },
27
16
  },
28
17
  } as never
29
18
 
30
19
  await assert.rejects(
31
- resolveArg(
32
- client,
33
- 'qwantani-moon-noon-puresky@1.0.2',
34
- 'environment',
35
- silentSpinner(),
36
- true,
37
- false,
38
- ),
20
+ resolveArg(client, 'qwantani-moon-noon-puresky@1.0.2', false),
39
21
  /has no approved versions/u,
40
22
  )
41
-
42
- assert.equal(searchCalls, 0)
43
23
  })
44
24
 
45
- test('resolveArg keeps explicit versioned asset refs exact when nothing matches yet', async () => {
46
- let searchCalls = 0
25
+ test('resolveArg keeps explicit versioned refs exact when the asset exists', async () => {
47
26
  const client = {
48
27
  asset: {
49
- exact: async () => null,
50
- search: async () => {
51
- searchCalls += 1
52
- return { items: [], total: 0, page: 1, limit: 1, totalPages: 0 }
53
- },
28
+ exact: async () => ({ name: 'fresh-sky', latestVersion: '1.2.3', approved: true }),
54
29
  },
55
30
  } as never
56
31
 
57
- const request = await resolveArg(
58
- client,
59
- 'fresh-sky@1.2.3',
60
- 'environment',
61
- silentSpinner(),
62
- true,
63
- false,
64
- )
32
+ const request = await resolveArg(client, 'fresh-sky@1.2.3', false)
65
33
 
66
34
  assert.deepEqual(request, { name: 'fresh-sky', range: '1.2.3' })
67
- assert.equal(searchCalls, 0)
68
35
  })
69
36
 
70
- function silentSpinner() {
71
- return {
72
- text: '',
73
- info() {},
74
- start() {
75
- return this
37
+ test('resolveArg errors when the asset does not exist instead of deferring', async () => {
38
+ const client = {
39
+ asset: {
40
+ exact: async () => null,
76
41
  },
77
42
  } as never
78
- }
43
+
44
+ await assert.rejects(resolveArg(client, 'does-not-exist@1.2.3', false), /not found/u)
45
+ })
@@ -30,9 +30,11 @@ test('install writes zip files into the package root', async () => {
30
30
  version: '1.0.0',
31
31
  npmDependencies: {},
32
32
  assetDependencies: {},
33
+ skillDependencies: {},
33
34
  },
34
35
  ],
35
36
  npmDependencies: {},
37
+ skillDependencies: {},
36
38
  },
37
39
  { cwd },
38
40
  )
@@ -46,6 +48,7 @@ test('install writes zip files into the package root', async () => {
46
48
  },
47
49
  ],
48
50
  npmDependencies: {},
51
+ skillDependencies: {},
49
52
  })
50
53
  assert.equal(
51
54
  await fs.readFile(path.join(appRoot, 'public', 'humanoid-animation', 'idle-loop.glb'), 'utf-8'),
@@ -75,9 +78,11 @@ test('install rejects zip paths that escape through parent segments', async () =
75
78
  version: '1.0.0',
76
79
  npmDependencies: {},
77
80
  assetDependencies: {},
81
+ skillDependencies: {},
78
82
  },
79
83
  ],
80
84
  npmDependencies: {},
85
+ skillDependencies: {},
81
86
  },
82
87
  { cwd: tempDir },
83
88
  ),
@@ -85,7 +90,60 @@ test('install rejects zip paths that escape through parent segments', async () =
85
90
  )
86
91
  })
87
92
 
88
- test('findInstallRoot ignores public directories and uses the highest package.json', async () => {
93
+ test('install runs the skills runner per dependency, resolving local paths against the root', async () => {
94
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'market-skills-'))
95
+ const appRoot = path.join(tempDir, 'app')
96
+ const cwd = path.join(appRoot, 'src')
97
+ await fs.mkdir(cwd, { recursive: true })
98
+ await fs.writeFile(path.join(appRoot, 'package.json'), '{}\n')
99
+
100
+ const calls: Array<{ source: string; cwd: string }> = []
101
+
102
+ const result = await install(
103
+ clientWithZip({
104
+ 'public/skills/local-skill/SKILL.md': textEncoder.encode('---\nname: local-skill\n---\n'),
105
+ }),
106
+ {
107
+ assets: [
108
+ {
109
+ name: 'with-skills',
110
+ version: '1.0.0',
111
+ npmDependencies: {},
112
+ assetDependencies: {},
113
+ skillDependencies: {},
114
+ },
115
+ ],
116
+ npmDependencies: {},
117
+ skillDependencies: {
118
+ 'web-design-guidelines': 'vercel-labs/agent-skills/tree/main/skills/web-design-guidelines',
119
+ 'local-skill': './public/skills/local-skill',
120
+ },
121
+ },
122
+ {
123
+ cwd,
124
+ runSkillAdd: async (source, runnerCwd) => {
125
+ calls.push({ source, cwd: runnerCwd })
126
+ },
127
+ },
128
+ )
129
+
130
+ // Every runner call uses the install root (the dir holding package.json).
131
+ assert.deepEqual(new Set(calls.map((c) => c.cwd)), new Set([appRoot]))
132
+
133
+ const bySource = new Map(calls.map((c) => [c.source, c]))
134
+ // Remote refs are passed through untouched.
135
+ assert.ok(bySource.has('vercel-labs/agent-skills/tree/main/skills/web-design-guidelines'))
136
+ // Local paths are resolved against the install root, not the cwd.
137
+ assert.ok(bySource.has(path.join(appRoot, 'public', 'skills', 'local-skill')))
138
+ assert.equal(calls.length, 2)
139
+
140
+ assert.deepEqual(result.skillDependencies, {
141
+ 'web-design-guidelines': 'vercel-labs/agent-skills/tree/main/skills/web-design-guidelines',
142
+ 'local-skill': './public/skills/local-skill',
143
+ })
144
+ })
145
+
146
+ test('findInstallRoot uses the nearest package.json, not the monorepo root', async () => {
89
147
  const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'market-root-'))
90
148
  const repoRoot = path.join(tempDir, 'repo')
91
149
  const appRoot = path.join(repoRoot, 'packages', 'app')
@@ -95,7 +153,7 @@ test('findInstallRoot ignores public directories and uses the highest package.js
95
153
  await fs.writeFile(path.join(repoRoot, 'package.json'), '{}\n')
96
154
  await fs.writeFile(path.join(appRoot, 'package.json'), '{}\n')
97
155
 
98
- assert.equal(await findInstallRoot(cwd), repoRoot)
156
+ assert.equal(await findInstallRoot(cwd), appRoot)
99
157
  })
100
158
 
101
159
  test('findInstallRoot falls back to cwd when no package.json exists', async () => {
@@ -18,6 +18,7 @@ test('installResult prints asset and npm lists with a file tree', () => {
18
18
  },
19
19
  ],
20
20
  npmDependencies: { '@react-three/drei': 'latest' },
21
+ skillDependencies: {},
21
22
  }),
22
23
  `Installed:
23
24
  - wooden-chair@1.2.0 (asset)
@@ -35,3 +36,20 @@ test('installResult prints asset and npm lists with a file tree', () => {
35
36
  - @react-three/drei@latest (npm)`,
36
37
  )
37
38
  })
39
+
40
+ test('installResult lists installed skills with their source', () => {
41
+ assert.equal(
42
+ installResult({
43
+ assets: [{ name: 'wooden-chair', version: '1.2.0', files: [] }],
44
+ npmDependencies: {},
45
+ skillDependencies: {
46
+ 'web-design-guidelines': 'vercel-labs/agent-skills/tree/main/skills/web-design-guidelines',
47
+ 'local-skill': '/abs/public/skills/local-skill',
48
+ },
49
+ }),
50
+ `Installed:
51
+ - wooden-chair@1.2.0 (asset)
52
+ - local-skill ← /abs/public/skills/local-skill (skill)
53
+ - web-design-guidelines ← vercel-labs/agent-skills/tree/main/skills/web-design-guidelines (skill)`,
54
+ )
55
+ })