slashdojo 0.1.0

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 (3) hide show
  1. package/package.json +39 -0
  2. package/src/cli.js +1068 -0
  3. package/src/index.js +355 -0
package/src/index.js ADDED
@@ -0,0 +1,355 @@
1
+ /**
2
+ * slashdojo — Agent-facing client for the Dojo registry.
3
+ *
4
+ * Usage:
5
+ * import { Dojo } from 'slashdojo';
6
+ * const hub = new Dojo();
7
+ * const skill = await hub.need('deploy a contract to Base');
8
+ * const result = await hub.run(skill, { contract_source: '...', chain: 'base' });
9
+ */
10
+
11
+ const DEFAULT_REGISTRY = 'https://slashdojo.com';
12
+ const LOCAL_FALLBACK_REGISTRY = 'http://localhost:3000';
13
+
14
+ function dedupe(values = []) {
15
+ return Array.from(new Set(values.filter(Boolean)));
16
+ }
17
+
18
+ export class Dojo {
19
+ constructor(opts = {}) {
20
+ const explicitRegistries = dedupe([
21
+ opts.registry,
22
+ ...(opts.registries || []),
23
+ process.env.DOJO_REGISTRY
24
+ ]);
25
+ this.registryCandidates = explicitRegistries.length
26
+ ? explicitRegistries
27
+ : [DEFAULT_REGISTRY, LOCAL_FALLBACK_REGISTRY];
28
+ this.registry = this.registryCandidates[0];
29
+ this.token = opts.token || process.env.DOJO_TOKEN || null;
30
+ this.cache = new Map();
31
+ this.capabilities = opts.capabilities || [];
32
+ this.envKeys = opts.envKeys || Object.keys(process.env);
33
+ }
34
+
35
+ // ─── Core: "I need X" ────────────────────────────────
36
+
37
+ /**
38
+ * Describe what you need in natural language. Returns the best matching skill.
39
+ * This is the primary method agents should call.
40
+ *
41
+ * @param {string} description - What capability you need
42
+ * @param {object} opts - Optional filters: { tags, type, limit }
43
+ * @returns {Promise<Skill|null>}
44
+ */
45
+ async need(description, opts = {}) {
46
+ const params = new URLSearchParams({ need: description });
47
+ if (opts.tags) params.set('tags', Array.isArray(opts.tags) ? opts.tags.join(',') : opts.tags);
48
+ if (opts.type) params.set('type', opts.type);
49
+ params.set('mode', opts.mode || 'do');
50
+ params.set('executable', String(opts.executable ?? !opts.type));
51
+ params.set('limit', String(opts.limit || 3));
52
+
53
+ const data = await this._fetch(`/v1/resolve?${params}`);
54
+ if (!data.results?.length) return null;
55
+
56
+ const best = data.results[0];
57
+ return this._enrichSkill(best.skill || best);
58
+ }
59
+
60
+ /**
61
+ * Ask the registry for a recommendation with full context.
62
+ * Returns the recommendation, missing env vars, and alternatives.
63
+ *
64
+ * @param {string} message - Natural language request
65
+ * @returns {Promise<Recommendation>}
66
+ */
67
+ async ask(message) {
68
+ const data = await this._fetch('/v1/agent/ask', {
69
+ method: 'POST',
70
+ body: {
71
+ message,
72
+ agent_context: {
73
+ capabilities: this.capabilities,
74
+ has_env: this.envKeys
75
+ }
76
+ }
77
+ });
78
+ return data;
79
+ }
80
+
81
+ // ─── Retrieval ───────────────────────────────────────
82
+
83
+ /**
84
+ * Get a skill by its URI.
85
+ * @param {string} uri - e.g. "openai/chat"
86
+ * @param {string} [version] - Optional version or range
87
+ * @returns {Promise<SkillWithContext>}
88
+ */
89
+ async get(uri, version) {
90
+ const cacheKey = `${uri}@${version || 'latest'}`;
91
+ if (this.cache.has(cacheKey)) return this.cache.get(cacheKey);
92
+
93
+ const params = version ? `?version=${version}` : '';
94
+ const data = await this._fetch(`/v1/skills/${uri}${params}`);
95
+
96
+ const result = {
97
+ skill: this._enrichSkill(data.skill),
98
+ ancestors: data.ancestors || [],
99
+ children: data.children || []
100
+ };
101
+
102
+ this.cache.set(cacheKey, result);
103
+ return result;
104
+ }
105
+
106
+ /**
107
+ * Search for skills by query string.
108
+ * @param {string} query
109
+ * @param {object} opts - { eco, type, tags, limit, offset }
110
+ * @returns {Promise<SearchResults>}
111
+ */
112
+ async search(query, opts = {}) {
113
+ const params = new URLSearchParams({ q: query });
114
+ for (const [k, v] of Object.entries(opts)) {
115
+ if (v != null) params.set(k, String(v));
116
+ }
117
+ return this._fetch(`/v1/search?${params}`);
118
+ }
119
+
120
+ /**
121
+ * Get the full tree for an ecosystem.
122
+ * @param {string} ecosystem - e.g. "openai"
123
+ * @param {number} [depth=4]
124
+ * @returns {Promise<Skill>}
125
+ */
126
+ async tree(ecosystem, depth = 4) {
127
+ return this._fetch(`/v1/tree/${ecosystem}?depth=${depth}`);
128
+ }
129
+
130
+ // ─── Execution ───────────────────────────────────────
131
+
132
+ /**
133
+ * Execute a skill's script. Resolves the skill, finds the script,
134
+ * checks env requirements, and runs it.
135
+ *
136
+ * @param {Skill|string} skillOrUri - Skill object or URI string
137
+ * @param {object} input - Input matching the skill's schema
138
+ * @param {string} [scriptId] - Specific script to run (defaults to first)
139
+ * @returns {Promise<any>}
140
+ */
141
+ async run(skillOrUri, input = {}, scriptId) {
142
+ const skill = typeof skillOrUri === 'string'
143
+ ? (await this.get(skillOrUri)).skill
144
+ : skillOrUri;
145
+
146
+ if (!skill.scripts?.length) {
147
+ throw new Error(`Skill ${skill.uri} has no executable scripts`);
148
+ }
149
+
150
+ const script = scriptId
151
+ ? skill.scripts.find(s => s.id === scriptId)
152
+ : skill.scripts[0];
153
+
154
+ if (!script) {
155
+ throw new Error(`Script "${scriptId}" not found in ${skill.uri}`);
156
+ }
157
+
158
+ // Check env requirements
159
+ const missing = this._checkEnv(script, input);
160
+ if (missing.length) {
161
+ throw new Error(
162
+ `Missing required environment variables for ${script.id}: ${missing.join(', ')}`
163
+ );
164
+ }
165
+
166
+ // Execute based on language
167
+ return this._execute(script, input);
168
+ }
169
+
170
+ /**
171
+ * Check what env vars are missing for a skill.
172
+ * @param {Skill} skill
173
+ * @returns {{ script: string, missing: string[] }[]}
174
+ */
175
+ checkRequirements(skill) {
176
+ return (skill.scripts || []).map(script => ({
177
+ script: script.id,
178
+ missing: this._checkEnv(script),
179
+ packages: script.packages || []
180
+ })).filter(r => r.missing.length > 0 || r.packages.length > 0);
181
+ }
182
+
183
+ // ─── Publishing ──────────────────────────────────────
184
+
185
+ /**
186
+ * Publish a skill to the registry.
187
+ * @param {Skill} skill - Full node manifest
188
+ * @returns {Promise<{ uri: string, version: string }>}
189
+ */
190
+ async publish(skill) {
191
+ if (!this.token) throw new Error('Auth token required for publishing');
192
+ return this._fetch('/v1/skills', {
193
+ method: 'POST',
194
+ body: skill,
195
+ auth: true
196
+ });
197
+ }
198
+
199
+ // ─── Composition ─────────────────────────────────────
200
+
201
+ /**
202
+ * Resolve a skill and all its dependencies into a flat list.
203
+ * @param {string} uri
204
+ * @returns {Promise<Skill[]>}
205
+ */
206
+ async resolve(uri) {
207
+ const { skill } = await this.get(uri);
208
+ const resolved = [skill];
209
+ const seen = new Set([uri]);
210
+
211
+ const deps = skill.depends?.filter(d => !d.optional) || [];
212
+ for (const dep of deps) {
213
+ if (seen.has(dep.uri)) continue;
214
+ seen.add(dep.uri);
215
+ try {
216
+ const depSkills = await this.resolve(dep.uri);
217
+ resolved.push(...depSkills.filter(s => !seen.has(s.uri)));
218
+ depSkills.forEach(s => seen.add(s.uri));
219
+ } catch {
220
+ // Optional deps that fail to resolve are skipped
221
+ }
222
+ }
223
+
224
+ return resolved;
225
+ }
226
+
227
+ /**
228
+ * Build a pipeline: resolve multiple skills and order by dependencies.
229
+ * @param {string[]} uris - Skills to compose
230
+ * @returns {Promise<Skill[]>} - Ordered execution plan
231
+ */
232
+ async pipeline(...uris) {
233
+ const all = [];
234
+ for (const uri of uris) {
235
+ const skills = await this.resolve(uri);
236
+ all.push(...skills);
237
+ }
238
+ // Deduplicate
239
+ const seen = new Set();
240
+ return all.filter(s => {
241
+ if (seen.has(s.uri)) return false;
242
+ seen.add(s.uri);
243
+ return true;
244
+ });
245
+ }
246
+
247
+ // ─── Internal ────────────────────────────────────────
248
+
249
+ async _fetch(path, opts = {}) {
250
+ if (path.startsWith('http')) {
251
+ return this._fetchOne(path, opts);
252
+ }
253
+
254
+ const errors = [];
255
+ for (const registry of this.registryCandidates) {
256
+ const url = `${registry}${path}`;
257
+ try {
258
+ const data = await this._fetchOne(url, opts);
259
+ this.registry = registry;
260
+ return data;
261
+ } catch (error) {
262
+ errors.push(`${registry}: ${error.message}`);
263
+ }
264
+ }
265
+
266
+ throw new Error(errors.join(' | '));
267
+ }
268
+
269
+ async _fetchOne(url, opts = {}) {
270
+ const headers = { 'Content-Type': 'application/json' };
271
+ if (opts.auth && this.token) {
272
+ headers['Authorization'] = `Bearer ${this.token}`;
273
+ }
274
+
275
+ const res = await fetch(url, {
276
+ method: opts.method || 'GET',
277
+ headers,
278
+ body: opts.body ? JSON.stringify(opts.body) : undefined
279
+ });
280
+
281
+ if (!res.ok) {
282
+ const err = await res.json().catch(() => ({ message: `HTTP ${res.status}` }));
283
+ throw new Error(err.message || err.error || `Registry error: ${res.status}`);
284
+ }
285
+
286
+ return res.json();
287
+ }
288
+
289
+ _enrichSkill(skill) {
290
+ if (!skill) return null;
291
+ // Add computed helpers
292
+ skill._requirements = this.checkRequirements(skill);
293
+ skill._ready = skill._requirements.every(r => r.missing.length === 0);
294
+ return skill;
295
+ }
296
+
297
+ _checkEnv(script, input = {}) {
298
+ if (!script.env) return [];
299
+ const hasInputValue = (key) => {
300
+ const lower = key.toLowerCase();
301
+ const camel = lower.replace(/_([a-z])/g, (_, char) => char.toUpperCase());
302
+ return input[key] != null || input[lower] != null || input[camel] != null;
303
+ };
304
+
305
+ return Object.entries(script.env)
306
+ .filter(([key, meta]) => meta.required && !hasInputValue(key) && process.env[key] == null)
307
+ .map(([key]) => key);
308
+ }
309
+
310
+ async _execute(script, input) {
311
+ if (script.lang === 'bash' && script.inline) {
312
+ const { execSync } = await import('child_process');
313
+ const result = execSync(script.inline, {
314
+ env: { ...process.env, ...input },
315
+ encoding: 'utf-8',
316
+ timeout: 30000
317
+ });
318
+ return result;
319
+ }
320
+
321
+ if (['javascript', 'typescript'].includes(script.lang) && script.inline) {
322
+ // Use the caller workspace so inline scripts can resolve local node_modules.
323
+ const { writeFileSync, unlinkSync } = await import('fs');
324
+ const { join } = await import('path');
325
+ const { pathToFileURL } = await import('url');
326
+ const tmpFile = join(process.cwd(), `.dojo_${script.id}_${Date.now()}.cjs`);
327
+
328
+ try {
329
+ writeFileSync(tmpFile, script.inline);
330
+ const mod = await import(pathToFileURL(tmpFile).href);
331
+ const fn = mod.default || mod[Object.keys(mod)[0]];
332
+ if (typeof fn === 'function') return await fn(input);
333
+ if (mod.default && typeof mod.default === 'object') {
334
+ const exported = mod.default;
335
+ const candidate = exported.default || exported[Object.keys(exported)[0]];
336
+ if (typeof candidate === 'function') return await candidate(input);
337
+ return exported;
338
+ }
339
+ return mod.default || mod;
340
+ } finally {
341
+ try { unlinkSync(tmpFile); } catch {}
342
+ }
343
+ }
344
+
345
+ throw new Error(`Execution not supported for lang: ${script.lang}`);
346
+ }
347
+ }
348
+
349
+ // ─── Convenience export ────────────────────────────────
350
+
351
+ export function createClient(opts) {
352
+ return new Dojo(opts);
353
+ }
354
+
355
+ export default Dojo;