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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-loopengine",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "description": "Scaffold a new loopengine agent project — a runnable HTTP server with one example agent, in your own repo.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,6 @@
1
+ node_modules
2
+ dist
3
+ .sessions
4
+ .env
5
+ *.log
6
+ .DS_Store
@@ -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\/([^/]+)$/)