@larkup/marketplace 0.1.1

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,191 @@
1
+ import type { ToolDescriptor, ToolCategory, ToolPricing } from './types';
2
+
3
+ /**
4
+ * Tool manifest schema validation.
5
+ *
6
+ * Provides a validation utility for `tool.manifest.json` files.
7
+ * Contributors creating new tools can validate their manifest
8
+ * against this schema to ensure correctness before publishing.
9
+ *
10
+ * Schema version: 1.0
11
+ */
12
+
13
+ /* ------------------------------------------------------------------ */
14
+ /* Constants */
15
+ /* ------------------------------------------------------------------ */
16
+
17
+ export const MANIFEST_SCHEMA_VERSION = '1.0';
18
+
19
+ export const VALID_CATEGORIES: ToolCategory[] = [
20
+ 'media',
21
+ 'search',
22
+ 'analytics',
23
+ 'integration',
24
+ 'embedding',
25
+ 'ai',
26
+ 'automation',
27
+ 'utility',
28
+ ];
29
+
30
+ export const VALID_PRICING_TIERS: ToolPricing[] = ['free', 'pro', 'enterprise'];
31
+
32
+ export const VALID_CONFIG_FIELD_TYPES = ['text', 'password', 'select', 'toggle'] as const;
33
+
34
+ /* ------------------------------------------------------------------ */
35
+ /* Validation result */
36
+ /* ------------------------------------------------------------------ */
37
+
38
+ export interface ManifestValidationResult {
39
+ valid: boolean;
40
+ errors: string[];
41
+ warnings: string[];
42
+ }
43
+
44
+ /* ------------------------------------------------------------------ */
45
+ /* Validator */
46
+ /* ------------------------------------------------------------------ */
47
+
48
+ /**
49
+ * Validate a tool manifest object against the ToolDescriptor schema.
50
+ *
51
+ * @example
52
+ * ```typescript
53
+ * import { validateToolManifest } from "@larkup/marketplace/manifest"
54
+ * import manifest from "./tool.manifest.json"
55
+ *
56
+ * const result = validateToolManifest(manifest)
57
+ * if (!result.valid) {
58
+ * console.error("Manifest errors:", result.errors)
59
+ * }
60
+ * ```
61
+ */
62
+ export function validateToolManifest(manifest: Record<string, unknown>): ManifestValidationResult {
63
+ const errors: string[] = [];
64
+ const warnings: string[] = [];
65
+
66
+ // Required string fields
67
+ const requiredStrings = [
68
+ 'id',
69
+ 'name',
70
+ 'description',
71
+ 'version',
72
+ 'packageName',
73
+ 'installSize',
74
+ 'author',
75
+ 'icon',
76
+ ] as const;
77
+ for (const field of requiredStrings) {
78
+ if (!manifest[field] || typeof manifest[field] !== 'string') {
79
+ errors.push(`Missing or invalid required field: "${field}" (expected string)`);
80
+ }
81
+ }
82
+
83
+ // id format: lowercase, hyphenated
84
+ if (typeof manifest.id === 'string' && !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(manifest.id)) {
85
+ errors.push(`"id" must be lowercase alphanumeric with hyphens (e.g., "video-audio")`);
86
+ }
87
+
88
+ // category
89
+ if (!manifest.category || !VALID_CATEGORIES.includes(manifest.category as ToolCategory)) {
90
+ errors.push(`"category" must be one of: ${VALID_CATEGORIES.join(', ')}`);
91
+ }
92
+
93
+ // pricing
94
+ if (!manifest.pricing || !VALID_PRICING_TIERS.includes(manifest.pricing as ToolPricing)) {
95
+ errors.push(`"pricing" must be one of: ${VALID_PRICING_TIERS.join(', ')}`);
96
+ }
97
+
98
+ // version semver-ish check
99
+ if (typeof manifest.version === 'string' && !/^\d+\.\d+\.\d+/.test(manifest.version)) {
100
+ errors.push(`"version" should follow semver format (e.g., "0.1.0")`);
101
+ }
102
+
103
+ // capabilities
104
+ if (!Array.isArray(manifest.capabilities) || manifest.capabilities.length === 0) {
105
+ errors.push(`"capabilities" must be a non-empty array of strings`);
106
+ }
107
+
108
+ // downloads must be a number
109
+ if (manifest.downloads !== undefined && typeof manifest.downloads !== 'number') {
110
+ errors.push(`"downloads" must be a number`);
111
+ }
112
+
113
+ // Optional field checks
114
+ if (manifest.emoji !== undefined && typeof manifest.emoji !== 'string') {
115
+ warnings.push(`"emoji" should be a string (e.g., "🎬")`);
116
+ }
117
+
118
+ if (manifest.tags !== undefined) {
119
+ if (!Array.isArray(manifest.tags)) {
120
+ warnings.push(`"tags" should be an array of strings`);
121
+ }
122
+ }
123
+
124
+ if (manifest.systemDeps !== undefined) {
125
+ if (!Array.isArray(manifest.systemDeps)) {
126
+ warnings.push(`"systemDeps" should be an array of strings`);
127
+ }
128
+ }
129
+
130
+ if (manifest.configSchema !== undefined) {
131
+ if (!Array.isArray(manifest.configSchema)) {
132
+ errors.push(`"configSchema" must be an array`);
133
+ } else {
134
+ for (let i = 0; i < manifest.configSchema.length; i++) {
135
+ const field = manifest.configSchema[i] as Record<string, unknown>;
136
+ if (!field.key || !field.label || !field.type) {
137
+ errors.push(`configSchema[${i}]: must have "key", "label", and "type"`);
138
+ }
139
+ if (field.type && !VALID_CONFIG_FIELD_TYPES.includes(field.type as any)) {
140
+ errors.push(
141
+ `configSchema[${i}].type must be one of: ${VALID_CONFIG_FIELD_TYPES.join(', ')}`,
142
+ );
143
+ }
144
+ }
145
+ }
146
+ }
147
+
148
+ // Warnings for missing recommended fields
149
+ if (!manifest.emoji && !manifest.iconUrl) {
150
+ warnings.push(`Consider adding "emoji" or "iconUrl" for a better marketplace experience`);
151
+ }
152
+ if (!manifest.tags || (Array.isArray(manifest.tags) && manifest.tags.length === 0)) {
153
+ warnings.push(`Consider adding "tags" for better discoverability`);
154
+ }
155
+ if (!manifest.license) {
156
+ warnings.push(`Consider adding "license" (e.g., "MIT", "Apache-2.0")`);
157
+ }
158
+ if (!manifest.repositoryUrl) {
159
+ warnings.push(`Consider adding "repositoryUrl" for collaboration`);
160
+ }
161
+
162
+ return {
163
+ valid: errors.length === 0,
164
+ errors,
165
+ warnings,
166
+ };
167
+ }
168
+
169
+ /**
170
+ * Generate a minimal tool manifest template.
171
+ * Useful for bootstrapping new tool packages.
172
+ */
173
+ export function generateManifestTemplate(toolId: string): Partial<ToolDescriptor> {
174
+ return {
175
+ id: toolId,
176
+ name: '',
177
+ description: '',
178
+ category: 'utility',
179
+ version: '0.1.0',
180
+ pricing: 'free',
181
+ emoji: '',
182
+ icon: '',
183
+ packageName: `@larkup/tool-${toolId}`,
184
+ installSize: '~1 MB',
185
+ author: '',
186
+ capabilities: [],
187
+ tags: [],
188
+ downloads: 0,
189
+ license: 'Apache-2.0',
190
+ };
191
+ }
@@ -0,0 +1,306 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import type { ToolDescriptor } from './types';
4
+ import { DEFAULT_HUB_URL } from './types';
5
+
6
+ /**
7
+ * Tool registry — resolves tool descriptors from multiple sources.
8
+ *
9
+ * Resolution order:
10
+ * 1. Local `tool.manifest.json` files from workspace packages
11
+ * 2. Hub API (remote catalog) when available
12
+ * 3. Hardcoded fallback for offline / development
13
+ *
14
+ * In the monorepo, tools under `packages/tools/` ship their own
15
+ * `tool.manifest.json`. This registry reads those at startup.
16
+ * When the Hub API is live, it supplements with remotely-published tools.
17
+ */
18
+
19
+ /* ------------------------------------------------------------------ */
20
+ /* Local manifest discovery */
21
+ /* ------------------------------------------------------------------ */
22
+
23
+ /** Cached registry — populated on first access. */
24
+ let cachedRegistry: Record<string, ToolDescriptor> | null = null;
25
+
26
+ /**
27
+ * Scan workspace tool directories for `tool.manifest.json` files.
28
+ * This is used in monorepo development and in Docker builds where
29
+ * tools are bundled at build time.
30
+ */
31
+ async function discoverLocalManifests(): Promise<Record<string, ToolDescriptor>> {
32
+ const registry: Record<string, ToolDescriptor> = {};
33
+
34
+ // Try multiple possible tool directories
35
+ const searchPaths = [
36
+ // Monorepo development: packages/tools/*
37
+ path.resolve(process.cwd(), 'packages', 'tools'),
38
+ // Next.js dev server runs in apps/web
39
+ path.resolve(process.cwd(), '..', '..', 'packages', 'tools'),
40
+ // Installed tools: .larkup/tools/node_modules/@larkup
41
+ path.resolve(process.cwd(), '.larkup', 'tools', 'node_modules', '@larkup'),
42
+ path.resolve(process.cwd(), '..', '..', '.larkup', 'tools', 'node_modules', '@larkup'),
43
+ ];
44
+
45
+ for (const searchPath of searchPaths) {
46
+ try {
47
+ const entries = await fs.readdir(searchPath, { withFileTypes: true });
48
+ for (const entry of entries) {
49
+ if (!entry.isDirectory()) continue;
50
+
51
+ // Check for tool.manifest.json in the directory
52
+ const manifestPath = path.join(searchPath, entry.name, 'tool.manifest.json');
53
+ try {
54
+ const raw = await fs.readFile(manifestPath, 'utf8');
55
+ const manifest = JSON.parse(raw) as ToolDescriptor;
56
+ if (manifest.id) {
57
+ registry[manifest.id] = manifest;
58
+ }
59
+ } catch {
60
+ // No manifest in this directory — skip
61
+ }
62
+ }
63
+ } catch {
64
+ // Directory doesn't exist — skip
65
+ }
66
+ }
67
+
68
+ return registry;
69
+ }
70
+
71
+ /* ------------------------------------------------------------------ */
72
+ /* Hub API fetching */
73
+ /* ------------------------------------------------------------------ */
74
+
75
+ /**
76
+ * Fetch the full tool catalog from the remote Hub API.
77
+ * Falls back gracefully if the Hub is unreachable.
78
+ */
79
+ async function fetchHubCatalog(hubUrl?: string): Promise<Record<string, ToolDescriptor>> {
80
+ const baseUrl = hubUrl ?? DEFAULT_HUB_URL;
81
+ try {
82
+ const controller = new AbortController();
83
+ const timeout = setTimeout(() => controller.abort(), 5000);
84
+
85
+ const res = await fetch(`${baseUrl}/v1/tools`, {
86
+ signal: controller.signal,
87
+ headers: { Accept: 'application/json' },
88
+ });
89
+ clearTimeout(timeout);
90
+
91
+ if (!res.ok) return {};
92
+
93
+ const data = (await res.json()) as { tools: ToolDescriptor[] };
94
+ const registry: Record<string, ToolDescriptor> = {};
95
+ for (const tool of data.tools ?? []) {
96
+ if (tool.id) registry[tool.id] = tool;
97
+ }
98
+ return registry;
99
+ } catch {
100
+ // Hub unreachable — offline mode
101
+ return {};
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Fetch a single tool descriptor from the Hub API.
107
+ */
108
+ export async function fetchToolFromHub(
109
+ toolId: string,
110
+ hubUrl?: string,
111
+ ): Promise<ToolDescriptor | null> {
112
+ const baseUrl = hubUrl ?? DEFAULT_HUB_URL;
113
+ try {
114
+ const controller = new AbortController();
115
+ const timeout = setTimeout(() => controller.abort(), 5000);
116
+
117
+ const res = await fetch(`${baseUrl}/v1/tools/${toolId}`, {
118
+ signal: controller.signal,
119
+ headers: { Accept: 'application/json' },
120
+ });
121
+ clearTimeout(timeout);
122
+
123
+ if (!res.ok) return null;
124
+ const data = (await res.json()) as { tool: ToolDescriptor };
125
+ return data.tool ?? null;
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ /* ------------------------------------------------------------------ */
132
+ /* Hardcoded fallback (offline safety net) */
133
+ /* ------------------------------------------------------------------ */
134
+
135
+ /**
136
+ * Minimal hardcoded registry for offline / first-run scenarios
137
+ * where neither manifests nor Hub are available. This ensures the
138
+ * marketplace always has something to show.
139
+ */
140
+ const FALLBACK_REGISTRY: Record<string, ToolDescriptor> = {
141
+ 'video-audio': {
142
+ id: 'video-audio',
143
+ name: 'Video & Audio',
144
+ description: 'Index video and audio files with transcription and frame analysis.',
145
+ category: 'media',
146
+ version: '0.1.0',
147
+ pricing: 'free',
148
+ emoji: '🎬',
149
+ icon: 'Film',
150
+ packageName: '@larkup/tool-video-audio',
151
+ installSize: '~15 MB',
152
+ systemDeps: ['ffmpeg'],
153
+ author: 'Larkup',
154
+ capabilities: [
155
+ 'video-indexing',
156
+ 'audio-indexing',
157
+ 'transcription',
158
+ 'frame-extraction',
159
+ 'youtube-import',
160
+ ],
161
+ tags: ['transcription', 'ffmpeg', 'video', 'audio', 'whisper'],
162
+ downloads: 0,
163
+ repositoryUrl: 'https://github.com/Larkup-AI/larkup-rag',
164
+ license: 'Apache-2.0',
165
+ updatedAt: '2026-07-20',
166
+ configSchema: [
167
+ {
168
+ key: 'frameInterval',
169
+ label: 'Frame extraction interval (seconds)',
170
+ type: 'text',
171
+ defaultValue: '10',
172
+ help: 'Baseline cadence for duration-aware coverage frames; scene changes and a reserved ending pass are added separately.',
173
+ },
174
+ {
175
+ key: 'audioProvider',
176
+ label: 'Audio Provider',
177
+ type: 'select',
178
+ help: 'Choose the provider used only for audio and video transcription.',
179
+ options: [
180
+ { label: 'OpenAI', value: 'openai' },
181
+ { label: 'Groq', value: 'groq' },
182
+ { label: 'Deepgram', value: 'deepgram' },
183
+ { label: 'ElevenLabs', value: 'elevenlabs' },
184
+ { label: 'Local Whisper', value: 'local' },
185
+ ],
186
+ },
187
+ {
188
+ key: 'audioLanguage',
189
+ label: 'Transcription language',
190
+ type: 'text',
191
+ defaultValue: 'auto',
192
+ help: 'Use auto to infer Arabic from the media title and use provider detection otherwise. Enter a language code such as ar, ar-EG, en, or de to override it.',
193
+ },
194
+ {
195
+ key: 'audioApiKey',
196
+ label: 'Audio API Key',
197
+ type: 'password',
198
+ help: 'Required for the selected audio provider and kept separate from the chat model key.',
199
+ },
200
+ ],
201
+ },
202
+
203
+ 'clip-embeddings': {
204
+ id: 'clip-embeddings',
205
+ name: 'CLIP Image Search',
206
+ description: 'Direct image-to-image similarity search using CLIP/SigLIP embeddings.',
207
+ category: 'embedding',
208
+ version: '0.1.0',
209
+ pricing: 'free',
210
+ emoji: '🔍',
211
+ icon: 'ScanEye',
212
+ packageName: '@larkup/tool-clip-embeddings',
213
+ installSize: '~200 MB',
214
+ author: 'Larkup',
215
+ capabilities: ['clip-embeddings', 'image-similarity'],
216
+ tags: ['clip', 'siglip', 'image-search', 'visual-similarity'],
217
+ downloads: 0,
218
+ repositoryUrl: 'https://github.com/Larkup-AI/larkup-rag',
219
+ license: 'Apache-2.0',
220
+ updatedAt: '2026-07-20',
221
+ comingSoon: true,
222
+ },
223
+
224
+ 'doc-editor': {
225
+ id: 'doc-editor',
226
+ name: 'Document Editor',
227
+ description: 'AI-powered form filling and document editing with Canvas-style live preview.',
228
+ category: 'utility',
229
+ version: '0.1.0',
230
+ pricing: 'free',
231
+ emoji: '📝',
232
+ icon: 'FileEdit',
233
+ packageName: '@larkup/tool-doc-editor',
234
+ installSize: '~5 MB',
235
+ systemDeps: ['docker'],
236
+ author: 'Larkup',
237
+ capabilities: ['document-editing', 'form-filling', 'document-preview'],
238
+ tags: ['pdf', 'docx', 'pptx', 'form', 'canvas', 'editor', 'fill'],
239
+ downloads: 0,
240
+ repositoryUrl: 'https://github.com/Larkup-AI/larkup-rag',
241
+ license: 'Apache-2.0',
242
+ updatedAt: '2026-07-20',
243
+ },
244
+ };
245
+
246
+ /* ------------------------------------------------------------------ */
247
+ /* Public API */
248
+ /* ------------------------------------------------------------------ */
249
+
250
+ /**
251
+ * Build the full tool registry by merging sources.
252
+ * Priority: local manifests > Hub API > hardcoded fallback.
253
+ * Cached after first call for the process lifetime.
254
+ */
255
+ export async function buildRegistry(opts?: {
256
+ hubUrl?: string;
257
+ skipHub?: boolean;
258
+ }): Promise<Record<string, ToolDescriptor>> {
259
+ if (cachedRegistry) return cachedRegistry;
260
+
261
+ // Start with hardcoded fallback
262
+ const registry = { ...FALLBACK_REGISTRY };
263
+
264
+ // Layer local manifests on top (they're the most up-to-date for development)
265
+ const localManifests = await discoverLocalManifests();
266
+ Object.assign(registry, localManifests);
267
+
268
+ // Layer Hub API catalog on top (newest published versions)
269
+ if (!opts?.skipHub) {
270
+ const hubCatalog = await fetchHubCatalog(opts?.hubUrl);
271
+ Object.assign(registry, hubCatalog);
272
+ }
273
+
274
+ cachedRegistry = registry;
275
+ return registry;
276
+ }
277
+
278
+ /** Force-refresh the registry cache (e.g., after installing a new tool). */
279
+ export function invalidateRegistryCache(): void {
280
+ cachedRegistry = null;
281
+ }
282
+
283
+ /** All tools as a flat list, sorted by name. */
284
+ export async function getAllTools(): Promise<ToolDescriptor[]> {
285
+ const registry = await buildRegistry();
286
+ return Object.values(registry).sort((a, b) => a.name.localeCompare(b.name));
287
+ }
288
+
289
+ /** Lookup a single tool by id. */
290
+ export async function getToolById(id: string): Promise<ToolDescriptor | undefined> {
291
+ const registry = await buildRegistry();
292
+ return registry[id];
293
+ }
294
+
295
+ /** Tools that provide a specific capability. */
296
+ export async function getToolsWithCapability(capability: string): Promise<ToolDescriptor[]> {
297
+ const all = await getAllTools();
298
+ return all.filter((t) => t.capabilities.includes(capability));
299
+ }
300
+
301
+ /** Get all unique categories from the registry. */
302
+ export async function getAllCategories(): Promise<string[]> {
303
+ const all = await getAllTools();
304
+ const cats = new Set(all.map((t) => t.category));
305
+ return Array.from(cats).sort();
306
+ }