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.
- package/package.json +39 -0
- package/src/cli.js +1068 -0
- 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;
|