create-loopengine 0.1.11 → 0.1.12
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/dist/cli.js +5 -1
- package/package.json +1 -1
- package/template/_gitignore +6 -0
- package/template/adapters/http.ts +111 -0
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
// Template ships in `template/` next to `dist/`, not inside it — it's a
|
|
@@ -15,6 +15,10 @@ export function scaffold(options) {
|
|
|
15
15
|
}
|
|
16
16
|
mkdirSync(destination, { recursive: true });
|
|
17
17
|
cpSync(templateDir, destination, { recursive: true });
|
|
18
|
+
// npm strips any .gitignore (even nested ones) when publishing a package,
|
|
19
|
+
// so the template ships it as `_gitignore` and this renames it back —
|
|
20
|
+
// same workaround create-vite/create-react-app use for the same reason.
|
|
21
|
+
renameSync(join(destination, '_gitignore'), join(destination, '.gitignore'));
|
|
18
22
|
const pkgPath = join(destination, 'package.json');
|
|
19
23
|
const pkg = readFileSync(pkgPath, 'utf8').replace('__PROJECT_NAME__', name);
|
|
20
24
|
writeFileSync(pkgPath, pkg);
|
package/package.json
CHANGED
|
@@ -29,11 +29,17 @@
|
|
|
29
29
|
// file's call — see AgentConfig.sessionIdFor and defaultSessionIdFor below.
|
|
30
30
|
import { randomUUID, timingSafeEqual } from 'node:crypto'
|
|
31
31
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
|
|
32
|
+
import { pathToFileURL } from 'node:url'
|
|
32
33
|
import { getEntry, listAgents, projectDir, registerAgent, updateAgent, type RegistryEntry } from '../agent-registry.js'
|
|
33
34
|
import {
|
|
34
35
|
loadAgentModule,
|
|
35
36
|
synthesizeCreateModelCall,
|
|
36
37
|
editAgentFile,
|
|
38
|
+
createHttpTool,
|
|
39
|
+
HttpToolNameError,
|
|
40
|
+
HttpToolExistsError,
|
|
41
|
+
HttpToolIndexShapeError,
|
|
42
|
+
type HttpToolSpec,
|
|
37
43
|
AgentEditNotSupportedError,
|
|
38
44
|
AgentFileNotFoundError,
|
|
39
45
|
type AgentEditResult,
|
|
@@ -441,6 +447,105 @@ async function handleGatewayToolSlugDelete(res: ServerResponse, agentName: strin
|
|
|
441
447
|
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ sources }))
|
|
442
448
|
}
|
|
443
449
|
|
|
450
|
+
function parseHttpToolBody(body: Record<string, unknown>): { ok: true; value: HttpToolSpec; decision?: Decision } | { ok: false; error: string } {
|
|
451
|
+
if (typeof body.name !== 'string' || !body.name) return { ok: false, error: 'name is required' }
|
|
452
|
+
if (typeof body.description !== 'string' || !body.description) return { ok: false, error: 'description is required' }
|
|
453
|
+
if (!Array.isArray(body.fields) || !body.fields.every((f) => f && typeof f === 'object')) {
|
|
454
|
+
return { ok: false, error: 'fields must be an array' }
|
|
455
|
+
}
|
|
456
|
+
const fields = body.fields as Record<string, unknown>[]
|
|
457
|
+
for (const f of fields) {
|
|
458
|
+
if (typeof f.name !== 'string' || !f.name) return { ok: false, error: 'every field needs a name' }
|
|
459
|
+
if (f.type !== 'string' && f.type !== 'number' && f.type !== 'boolean') return { ok: false, error: `field "${f.name}" has an invalid type` }
|
|
460
|
+
}
|
|
461
|
+
const method = body.method
|
|
462
|
+
if (method !== 'GET' && method !== 'POST' && method !== 'PUT' && method !== 'PATCH' && method !== 'DELETE') {
|
|
463
|
+
return { ok: false, error: 'method must be one of GET, POST, PUT, PATCH, DELETE' }
|
|
464
|
+
}
|
|
465
|
+
if (typeof body.url !== 'string' || !body.url) return { ok: false, error: 'url is required' }
|
|
466
|
+
if (!Array.isArray(body.headers) || !body.headers.every((h) => h && typeof h === 'object' && typeof (h as Record<string, unknown>).key === 'string' && typeof (h as Record<string, unknown>).value === 'string')) {
|
|
467
|
+
return { ok: false, error: 'headers must be an array of {key, value} strings' }
|
|
468
|
+
}
|
|
469
|
+
if (body.responseJsonPath !== undefined && typeof body.responseJsonPath !== 'string') {
|
|
470
|
+
return { ok: false, error: 'responseJsonPath must be a string' }
|
|
471
|
+
}
|
|
472
|
+
if (body.decision !== undefined && !isDecision(body.decision)) {
|
|
473
|
+
return { ok: false, error: 'decision must be allow, ask, or deny' }
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return {
|
|
477
|
+
ok: true,
|
|
478
|
+
value: {
|
|
479
|
+
name: body.name,
|
|
480
|
+
description: body.description,
|
|
481
|
+
fields: fields as unknown as HttpToolSpec['fields'],
|
|
482
|
+
method,
|
|
483
|
+
url: body.url,
|
|
484
|
+
headers: body.headers as HttpToolSpec['headers'],
|
|
485
|
+
sendFieldsAsJsonBody: body.sendFieldsAsJsonBody === true,
|
|
486
|
+
responseJsonPath: body.responseJsonPath as string | undefined,
|
|
487
|
+
},
|
|
488
|
+
decision: body.decision as Decision | undefined,
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Backs the Tools tab's "Create an HTTP tool" form — generates a real
|
|
493
|
+
// agents/:name/tools/<tool>.ts file (loopengine's own createHttpTool),
|
|
494
|
+
// then splices the freshly-imported ToolDefinition straight into this
|
|
495
|
+
// process's own live registry via updateAgent (../agent-registry.js) —
|
|
496
|
+
// the same "no restart needed" mechanism a newly-created agent already
|
|
497
|
+
// gets (see registerAgent's own doc comment), and deliberately not a
|
|
498
|
+
// reliance on tsx watch picking up the new file: this way the tool is
|
|
499
|
+
// callable immediately under `serve` (no watch) too, not just `dev`. An
|
|
500
|
+
// optional `decision` seeds one actauth.yml rule via the already-existing
|
|
501
|
+
// addActauthRule, mirroring gateway-tools.ts's own "a rule is opt-in,
|
|
502
|
+
// omitting it falls through to default_decision" precedent — never a
|
|
503
|
+
// hardcoded default here.
|
|
504
|
+
async function handleHttpToolPost(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
|
|
505
|
+
const entry = getEntry(agentName)
|
|
506
|
+
if (!entry) {
|
|
507
|
+
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
508
|
+
return
|
|
509
|
+
}
|
|
510
|
+
const body = await readJsonBody(req)
|
|
511
|
+
const parsed = parseHttpToolBody(body)
|
|
512
|
+
if (!parsed.ok) {
|
|
513
|
+
res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: parsed.error }))
|
|
514
|
+
return
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
let tool: ToolDefinition
|
|
518
|
+
try {
|
|
519
|
+
const result = await createHttpTool(agentName, parsed.value)
|
|
520
|
+
tool = result.tool
|
|
521
|
+
} catch (err) {
|
|
522
|
+
const status = err instanceof HttpToolExistsError ? 409 : err instanceof HttpToolNameError || err instanceof HttpToolIndexShapeError ? 400 : 500
|
|
523
|
+
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
524
|
+
return
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const currentTools = entry.config.tools ?? (await loadDefaultTools(entry.config))
|
|
528
|
+
updateAgent(agentName, { config: { tools: [...currentTools, tool] } })
|
|
529
|
+
|
|
530
|
+
if (parsed.decision) {
|
|
531
|
+
try {
|
|
532
|
+
addActauthRule(agentName, { name: `${parsed.value.name}-admin-created`, scope: '*/*', tool: parsed.value.name, decision: parsed.decision })
|
|
533
|
+
} catch (err) {
|
|
534
|
+
// The tool file and live registration above already succeeded —
|
|
535
|
+
// a rule-seeding failure (near-impossible: only real cause is a
|
|
536
|
+
// same-named rule already existing) shouldn't roll either back,
|
|
537
|
+
// just surface as a warning-shaped success so the operator can
|
|
538
|
+
// add the rule by hand via the Actauth tab instead.
|
|
539
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(
|
|
540
|
+
JSON.stringify({ tool: describeTool(tool), ruleWarning: err instanceof Error ? err.message : String(err) }),
|
|
541
|
+
)
|
|
542
|
+
return
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ tool: describeTool(tool) }))
|
|
547
|
+
}
|
|
548
|
+
|
|
444
549
|
// Backs GET /composio/connections and GET /composio/tools — not scoped
|
|
445
550
|
// under /agents/:name/ like the routes above, deliberately: which apps
|
|
446
551
|
// are connected and what they offer isn't a property of any one agent,
|
|
@@ -1779,6 +1884,12 @@ const server = createServer(async (req, res) => {
|
|
|
1779
1884
|
return
|
|
1780
1885
|
}
|
|
1781
1886
|
|
|
1887
|
+
const httpToolMatch = req.method === 'POST' && pathname.match(/^\/agents\/([^/]+)\/tools\/http$/)
|
|
1888
|
+
if (httpToolMatch) {
|
|
1889
|
+
await handleHttpToolPost(req, res, decodeURIComponent(httpToolMatch[1]))
|
|
1890
|
+
return
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1782
1893
|
// Backs the Skills tab's edit form — see skills-admin.ts for why
|
|
1783
1894
|
// :skillId is restricted to a flat (non-nested) id.
|
|
1784
1895
|
const skillMatch = pathname.match(/^\/agents\/([^/]+)\/skills\/([^/]+)$/)
|