@series-inc/stowkit-cli 0.6.16 → 0.6.18

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.
@@ -0,0 +1,48 @@
1
+ import type { Registry } from './assets-package.js';
2
+ export declare function fetchRegistry(bucket?: string): Promise<Registry>;
3
+ export interface SearchResult {
4
+ stringId: string;
5
+ type: string;
6
+ packageName: string;
7
+ version: string;
8
+ file: string;
9
+ size: number;
10
+ tags: string[];
11
+ dependencies: string[];
12
+ thumbnail: boolean;
13
+ thumbnailFormat: 'png' | 'webp' | 'webm';
14
+ thumbnailUrl: string | null;
15
+ filtering?: 'linear' | 'nearest';
16
+ }
17
+ export interface PackageInfo {
18
+ name: string;
19
+ description: string;
20
+ author: string;
21
+ tags: string[];
22
+ latest: string;
23
+ versions: string[];
24
+ assetCount: number;
25
+ totalSize: number;
26
+ }
27
+ export declare function searchAssets(registry: Registry, query: string, opts?: {
28
+ type?: string;
29
+ package?: string;
30
+ }): SearchResult[];
31
+ export declare function listPackages(registry: Registry): PackageInfo[];
32
+ export declare function resolveAssetDeps(registry: Registry, packageName: string, stringIds: string[], version?: string): {
33
+ resolvedIds: string[];
34
+ files: string[];
35
+ };
36
+ export declare function storeSearch(query: string, opts?: {
37
+ type?: string;
38
+ json?: boolean;
39
+ bucket?: string;
40
+ }): Promise<void>;
41
+ export declare function storeList(opts?: {
42
+ json?: boolean;
43
+ bucket?: string;
44
+ }): Promise<void>;
45
+ export declare function storeInfo(packageName: string, opts?: {
46
+ json?: boolean;
47
+ bucket?: string;
48
+ }): Promise<void>;
package/dist/store.js ADDED
@@ -0,0 +1,300 @@
1
+ import { resolveTransitiveDeps, resolveFiles } from './assets-package.js';
2
+ const DEFAULT_BUCKET = 'venus-shared-assets-test';
3
+ // ─── Fetch Registry ──────────────────────────────────────────────────────────
4
+ export async function fetchRegistry(bucket) {
5
+ const bucketName = (bucket ?? DEFAULT_BUCKET).replace(/^gs:\/\//, '').replace(/\/$/, '');
6
+ const url = `https://storage.googleapis.com/${bucketName}/registry.json?t=${Date.now()}`;
7
+ const res = await fetch(url);
8
+ if (!res.ok) {
9
+ if (res.status === 404)
10
+ return { schemaVersion: 1, packages: {} };
11
+ throw new Error(`Failed to fetch registry: ${res.status}`);
12
+ }
13
+ return res.json();
14
+ }
15
+ // ─── Search ──────────────────────────────────────────────────────────────────
16
+ export function searchAssets(registry, query, opts) {
17
+ // Support comma-separated terms: "coral, sea, ocean" matches any term
18
+ const terms = query.split(',').map(t => t.toLowerCase().trim()).filter(Boolean);
19
+ const scored = [];
20
+ const bucket = DEFAULT_BUCKET;
21
+ // When searching within a specific package, skip package-level metadata
22
+ // (name, description, tags) — it matches every asset and adds noise.
23
+ const skipPkgMeta = !!opts?.package;
24
+ for (const [pkgName, pkg] of Object.entries(registry.packages)) {
25
+ if (opts?.package && pkgName !== opts.package)
26
+ continue;
27
+ const verStr = pkg.latest;
28
+ const ver = pkg.versions[verStr];
29
+ if (!ver)
30
+ continue;
31
+ for (const asset of ver.assets) {
32
+ if (opts?.type && asset.type !== opts.type)
33
+ continue;
34
+ const score = terms.length === 0
35
+ ? 1
36
+ : scoreAsset(terms, asset, pkg, pkgName, skipPkgMeta);
37
+ if (score === 0)
38
+ continue;
39
+ scored.push({
40
+ score,
41
+ result: {
42
+ stringId: asset.stringId,
43
+ type: asset.type,
44
+ packageName: pkgName,
45
+ version: verStr,
46
+ file: asset.file,
47
+ size: asset.size,
48
+ tags: asset.tags,
49
+ dependencies: asset.dependencies,
50
+ thumbnail: !!asset.thumbnail,
51
+ thumbnailFormat: asset.thumbnailFormat ?? 'png',
52
+ thumbnailUrl: asset.thumbnail
53
+ ? `https://storage.googleapis.com/${bucket}/packages/${pkgName}/${verStr}/thumbnails/${asset.stringId.split('/').map(encodeURIComponent).join('/')}.${asset.thumbnailFormat ?? 'png'}`
54
+ : null,
55
+ ...(asset.filtering ? { filtering: asset.filtering } : {}),
56
+ },
57
+ });
58
+ }
59
+ }
60
+ // Sort by score descending, then alphabetically by stringId
61
+ scored.sort((a, b) => b.score - a.score || a.result.stringId.localeCompare(b.result.stringId));
62
+ return scored.map(s => s.result);
63
+ }
64
+ /**
65
+ * Split a string into lowercase words on common boundaries:
66
+ * underscores, hyphens, dots, slashes, camelCase, spaces.
67
+ * e.g. "NinjaBoss_Attack" → ["ninja", "boss", "attack"]
68
+ */
69
+ function splitWords(s) {
70
+ return s
71
+ // insert boundary before uppercase runs: "NinjaBoss" → "Ninja Boss"
72
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
73
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
74
+ .split(/[_\-./\\\s]+/)
75
+ .map(w => w.toLowerCase())
76
+ .filter(Boolean);
77
+ }
78
+ /**
79
+ * Minimum per-term score for a term to count as matched.
80
+ * Prevents weak incidental hits (file-path substring, type contains)
81
+ * from surfacing assets on their own — they can only boost an
82
+ * already-qualifying match.
83
+ */
84
+ const TERM_THRESHOLD = 20;
85
+ /**
86
+ * Score an asset against search terms. Returns 0 if the asset should
87
+ * not appear in results.
88
+ *
89
+ * Design principles:
90
+ * - Curated metadata (tags) scores highest after exact name matches,
91
+ * because someone intentionally labelled the asset.
92
+ * - AND semantics: every term must clear TERM_THRESHOLD independently.
93
+ * "coral reef" only matches assets that match BOTH "coral" AND "reef".
94
+ * - Weak signals (file-path word, type name) act as tiebreaker boosts
95
+ * but can't qualify an asset on their own.
96
+ *
97
+ * Per-term scoring (best match wins):
98
+ * 100 — exact stringId match
99
+ * 80 — exact tag match (curated, high-intent)
100
+ * 60 — exact word in stringId ("boss" in "actor/boss/idle")
101
+ * 50 — stringId starts with term
102
+ * 45 — word in tag starts with term ("bos" → "boss" tag)
103
+ * 40 — word in stringId starts with term ("att" → "attack")
104
+ * 30 — stringId contains term as substring
105
+ * 25 — tag contains term as substring
106
+ * 90 — exact package name match (cross-pack only)
107
+ * 50 — word in package name matches (cross-pack only)
108
+ * 45 — exact package tag match (cross-pack only)
109
+ * 35 — package name/tag word prefix (cross-pack only)
110
+ * 25 — package name/tag substring (cross-pack only)
111
+ * ── below TERM_THRESHOLD — boost only, can't qualify alone ──
112
+ * 15 — exact word in file path
113
+ * 12 — word in file path starts with term
114
+ * 10 — asset type / package description contains term
115
+ */
116
+ function scoreAsset(terms, asset, pkg, pkgName, skipPkgMeta = false) {
117
+ let total = 0;
118
+ const sid = asset.stringId.toLowerCase();
119
+ const sidWords = splitWords(asset.stringId);
120
+ const fileWords = splitWords(asset.file);
121
+ for (const t of terms) {
122
+ let best = 0;
123
+ // ── Strong signals (can qualify on their own) ──────────────────
124
+ // stringId
125
+ if (sid === t)
126
+ best = 100;
127
+ else if (sidWords.some(w => w === t))
128
+ best = Math.max(best, 60);
129
+ else if (sid.startsWith(t))
130
+ best = Math.max(best, 50);
131
+ else if (sidWords.some(w => w.startsWith(t)))
132
+ best = Math.max(best, 40);
133
+ else if (sid.includes(t))
134
+ best = Math.max(best, 30);
135
+ // tags (curated — score high)
136
+ if (asset.tags.some(tag => tag.toLowerCase() === t))
137
+ best = Math.max(best, 80);
138
+ else if (asset.tags.some(tag => splitWords(tag).some(w => w === t)))
139
+ best = Math.max(best, 60);
140
+ else if (asset.tags.some(tag => splitWords(tag).some(w => w.startsWith(t))))
141
+ best = Math.max(best, 45);
142
+ else if (asset.tags.some(tag => tag.toLowerCase().includes(t)))
143
+ best = Math.max(best, 25);
144
+ // ── Weak signals (boost only, below threshold) ─────────────────
145
+ // file path (word-level only, no raw substring)
146
+ if (fileWords.some(w => w === t))
147
+ best = Math.max(best, 15);
148
+ else if (fileWords.some(w => w.startsWith(t)))
149
+ best = Math.max(best, 12);
150
+ // asset type
151
+ if (asset.type.toLowerCase().includes(t))
152
+ best = Math.max(best, 10);
153
+ // package metadata (cross-pack discovery only)
154
+ if (!skipPkgMeta) {
155
+ const pkgLower = pkgName.toLowerCase();
156
+ const pkgWords = splitWords(pkgName);
157
+ // Pack name — strongest cross-pack signal
158
+ if (pkgLower === t)
159
+ best = Math.max(best, 90);
160
+ else if (pkgWords.some(w => w === t))
161
+ best = Math.max(best, 50);
162
+ else if (pkgWords.some(w => w.startsWith(t)))
163
+ best = Math.max(best, 35);
164
+ else if (pkgLower.includes(t))
165
+ best = Math.max(best, 25);
166
+ // Pack tags — curated, high signal
167
+ if ((pkg.tags ?? []).some(tag => tag.toLowerCase() === t))
168
+ best = Math.max(best, 45);
169
+ else if ((pkg.tags ?? []).some(tag => splitWords(tag).some(w => w === t || w.startsWith(t))))
170
+ best = Math.max(best, 35);
171
+ else if ((pkg.tags ?? []).some(tag => tag.toLowerCase().includes(t)))
172
+ best = Math.max(best, 25);
173
+ // Pack description — weakest, just a boost
174
+ if (pkg.description.toLowerCase().includes(t))
175
+ best = Math.max(best, 10);
176
+ }
177
+ // AND semantics: if this term didn't clear the threshold,
178
+ // the asset doesn't qualify — bail out early.
179
+ if (best < TERM_THRESHOLD)
180
+ return 0;
181
+ total += best;
182
+ }
183
+ return total;
184
+ }
185
+ // ─── List Packages ───────────────────────────────────────────────────────────
186
+ export function listPackages(registry) {
187
+ return Object.entries(registry.packages).map(([name, pkg]) => {
188
+ const ver = pkg.versions[pkg.latest];
189
+ return {
190
+ name,
191
+ description: pkg.description,
192
+ author: pkg.author,
193
+ tags: pkg.tags ?? [],
194
+ latest: pkg.latest,
195
+ versions: Object.keys(pkg.versions),
196
+ assetCount: ver?.assets.length ?? 0,
197
+ totalSize: ver?.totalSize ?? 0,
198
+ };
199
+ });
200
+ }
201
+ // ─── Resolve Dependencies ────────────────────────────────────────────────────
202
+ export function resolveAssetDeps(registry, packageName, stringIds, version) {
203
+ const pkg = registry.packages[packageName];
204
+ if (!pkg)
205
+ throw new Error(`Package "${packageName}" not found`);
206
+ const verStr = version ?? pkg.latest;
207
+ const ver = pkg.versions[verStr];
208
+ if (!ver)
209
+ throw new Error(`Version "${verStr}" not found`);
210
+ const resolvedIds = resolveTransitiveDeps(stringIds, ver.assets);
211
+ const files = resolveFiles(resolvedIds, ver.assets);
212
+ return { resolvedIds, files };
213
+ }
214
+ // ─── CLI Commands ────────────────────────────────────────────────────────────
215
+ export async function storeSearch(query, opts) {
216
+ const registry = await fetchRegistry(opts?.bucket);
217
+ const results = searchAssets(registry, query, { type: opts?.type });
218
+ if (opts?.json) {
219
+ console.log(JSON.stringify(results, null, 2));
220
+ return;
221
+ }
222
+ if (results.length === 0) {
223
+ console.log(`No assets found matching "${query}"`);
224
+ return;
225
+ }
226
+ console.log(`\nFound ${results.length} asset${results.length !== 1 ? 's' : ''} matching "${query}":\n`);
227
+ for (const r of results) {
228
+ const tags = r.tags.length > 0 ? ` [${r.tags.join(', ')}]` : '';
229
+ const deps = r.dependencies.length > 0 ? ` → ${r.dependencies.join(', ')}` : '';
230
+ const thumb = r.thumbnail ? ' (has thumbnail)' : '';
231
+ console.log(` [${r.type}] ${r.stringId} — ${r.packageName}@${r.version}${tags}${deps}${thumb}`);
232
+ console.log(` ${r.file} (${formatBytes(r.size)})`);
233
+ }
234
+ console.log('');
235
+ }
236
+ export async function storeList(opts) {
237
+ const registry = await fetchRegistry(opts?.bucket);
238
+ const packages = listPackages(registry);
239
+ if (opts?.json) {
240
+ console.log(JSON.stringify(packages, null, 2));
241
+ return;
242
+ }
243
+ if (packages.length === 0) {
244
+ console.log('No packages published yet.');
245
+ return;
246
+ }
247
+ console.log(`\n${packages.length} package${packages.length !== 1 ? 's' : ''} in the asset store:\n`);
248
+ for (const p of packages) {
249
+ const tags = p.tags.length > 0 ? ` [${p.tags.join(', ')}]` : '';
250
+ const desc = p.description ? ` — ${p.description}` : '';
251
+ console.log(` ${p.name}@${p.latest}${desc}${tags}`);
252
+ console.log(` ${p.assetCount} assets, ${formatBytes(p.totalSize)}, ${p.versions.length} version${p.versions.length !== 1 ? 's' : ''}`);
253
+ }
254
+ console.log('');
255
+ }
256
+ export async function storeInfo(packageName, opts) {
257
+ const registry = await fetchRegistry(opts?.bucket);
258
+ const pkg = registry.packages[packageName];
259
+ if (!pkg) {
260
+ console.error(`Package "${packageName}" not found.`);
261
+ process.exit(1);
262
+ }
263
+ const ver = pkg.versions[pkg.latest];
264
+ if (opts?.json) {
265
+ console.log(JSON.stringify({
266
+ name: packageName,
267
+ description: pkg.description,
268
+ author: pkg.author,
269
+ tags: pkg.tags ?? [],
270
+ latest: pkg.latest,
271
+ versions: Object.keys(pkg.versions),
272
+ assets: ver?.assets ?? [],
273
+ }, null, 2));
274
+ return;
275
+ }
276
+ console.log(`\n${packageName}@${pkg.latest}`);
277
+ if (pkg.description)
278
+ console.log(` ${pkg.description}`);
279
+ if (pkg.author)
280
+ console.log(` Author: ${pkg.author}`);
281
+ if (pkg.tags?.length)
282
+ console.log(` Tags: ${pkg.tags.join(', ')}`);
283
+ console.log(` Versions: ${Object.keys(pkg.versions).join(', ')}`);
284
+ if (ver) {
285
+ console.log(`\nAssets (${ver.assets.length}):\n`);
286
+ for (const a of ver.assets) {
287
+ const tags = a.tags.length > 0 ? ` [${a.tags.join(', ')}]` : '';
288
+ const deps = a.dependencies.length > 0 ? ` → ${a.dependencies.join(', ')}` : '';
289
+ console.log(` [${a.type}] ${a.stringId}${tags}${deps} (${formatBytes(a.size)})`);
290
+ }
291
+ }
292
+ console.log('');
293
+ }
294
+ function formatBytes(bytes) {
295
+ if (bytes < 1024)
296
+ return `${bytes} B`;
297
+ if (bytes < 1024 * 1024)
298
+ return `${(bytes / 1024).toFixed(1)} KB`;
299
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
300
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@series-inc/stowkit-cli",
3
- "version": "0.6.16",
3
+ "version": "0.6.18",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "stowkit": "./dist/cli.js"
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "@series-inc/stowkit-packer-gui": "^0.1.17",
21
- "@series-inc/stowkit-editor": "^0.1.6",
21
+ "@series-inc/stowkit-editor": "^0.1.8",
22
22
  "draco3d": "^1.5.7",
23
23
  "fbx-parser": "^2.1.3",
24
24
  "@strangeape/ffmpeg-audio-wasm": "^0.1.0",
package/skill.md CHANGED
@@ -53,6 +53,9 @@ stowkit move <path> <folder> # Move an asset to a different folder (updates G
53
53
  stowkit delete <path> # Delete an asset and its .stowmeta/.stowcache files
54
54
  stowkit set-id <path> <id> # Change an asset's stringId
55
55
  stowkit inspect <file.stow> # Show manifest of a built .stow pack (use -v for details)
56
+ stowkit store search <query> # Search the asset store (add --json for machine-readable output)
57
+ stowkit store list # List all packages in the store
58
+ stowkit store info <package> # Show package details and all assets
56
59
  stowkit packer [dir] # Open the packer GUI in browser
57
60
  stowkit editor [dir] # Open the level editor in browser
58
61
  stowkit serve [dir] # Start API server only (no GUI)
@@ -63,6 +66,8 @@ All commands default to the current directory.
63
66
  **Options:**
64
67
  - `--force` — Ignore cache and reprocess everything
65
68
  - `--verbose` / `-v` — Detailed output
69
+ - `--json` — Output store results as JSON (for programmatic/AI use)
70
+ - `--type <type>` — Filter store search by asset type (e.g. `staticMesh`, `texture`)
66
71
  - `--port <number>` — Server port (default 3210)
67
72
  - `--schema <name>` — Material schema template for `create-material` (default: `pbr`)
68
73
 
@@ -525,6 +530,59 @@ To refresh skill files without updating the CLI: `stowkit init --update`
525
530
 
526
531
  **When to run this:** If you notice this skill file is missing documentation for commands that exist in `stowkit --help`, or if the user asks you to update StowKit.
527
532
 
533
+ ## Asset Store
534
+
535
+ StowKit includes a shared asset store for publishing, searching, and importing reusable asset packs across projects. Assets are stored in a public GCS bucket with a central `registry.json` manifest.
536
+
537
+ ### Searching the store
538
+
539
+ ```bash
540
+ stowkit store search coral # Find assets matching "coral"
541
+ stowkit store search ocean --type texture # Find textures tagged/named "ocean"
542
+ stowkit store search staticMesh --json # Machine-readable JSON output
543
+ stowkit store list # List all published packages
544
+ stowkit store info package_test # Show all assets in a package
545
+ stowkit store info package_test --json # Full package details as JSON
546
+ ```
547
+
548
+ The `--json` flag outputs structured data with `stringId`, `type`, `packageName`, `version`, `file`, `size`, `tags`, `dependencies`, `thumbnail`, and `thumbnailUrl` for each asset. This is the recommended format for AI agents to consume.
549
+
550
+ ### JSON search result format
551
+
552
+ ```json
553
+ [
554
+ {
555
+ "stringId": "coral_1",
556
+ "type": "staticMesh",
557
+ "packageName": "ocean_pack",
558
+ "version": "1.0.0",
559
+ "file": "Meshes/Coral_1.fbx",
560
+ "size": 59952,
561
+ "tags": ["coral", "environment"],
562
+ "dependencies": ["M_Sea_Floor"],
563
+ "thumbnail": true,
564
+ "thumbnailUrl": "https://storage.googleapis.com/venus-shared-assets-test/packages/ocean_pack/1.0.0/thumbnails/coral_1.png"
565
+ }
566
+ ]
567
+ ```
568
+
569
+ ### Dependency resolution
570
+
571
+ Assets have dependency chains: meshes depend on materials, materials depend on textures. When importing an asset, all transitive dependencies are automatically resolved and downloaded. If two assets share the same dependency, it's only downloaded once.
572
+
573
+ ### Importing from the store
574
+
575
+ The packer GUI has a **Store** button to browse, search, and import assets. Selected assets and their transitive dependencies are downloaded directly into the project's `srcArtDir`.
576
+
577
+ ### Thumbnail URLs
578
+
579
+ Published assets may have thumbnails at:
580
+ ```
581
+ https://storage.googleapis.com/{bucket}/packages/{packageName}/{version}/thumbnails/{stringId}.png
582
+ ```
583
+
584
+ Check the `thumbnail` boolean in search results to know if a thumbnail exists.
585
+
528
586
  ## Common Tasks for AI Agents
529
587
 
530
588
  ### Adding a GLB model (recommended 3D workflow)
@@ -590,3 +648,8 @@ This is useful for verifying build output, checking which assets ended up in whi
590
648
  - **Clean orphaned files:** `stowkit clean`
591
649
  - **Set up with engine:** `stowkit init --with-engine` (installs `@series-inc/rundot-3d-engine` + `three`)
592
650
  - **Update CLI + skill files:** `stowkit update`
651
+ - **Search the asset store:** `stowkit store search sword --json` (returns JSON array of matching assets with thumbnailUrls)
652
+ - **Find all meshes in the store:** `stowkit store search mesh --type staticMesh --json`
653
+ - **List all published packages:** `stowkit store list --json`
654
+ - **Get package details:** `stowkit store info ocean_pack --json`
655
+ - **Show a thumbnail to the user:** Use the `thumbnailUrl` from search results — it's a public PNG URL