@eventmodelers/cli 2.0.9 → 2.0.11

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,111 @@
1
+ // Slices → a neutral EventCatalog model: one domain, one flow per chapter, and the events, commands and queries
2
+ // (read models) of the event model. Processors, screens and services are not part of it — the model has no
3
+ // such thing as a service, so none is imported or invented. Pure — no I/O — so it is testable without a catalog.
4
+
5
+ const ELEMENT_KINDS = [
6
+ ['commands', 'command'],
7
+ ['events', 'event'],
8
+ ['readmodels', 'query'],
9
+ ];
10
+ const DEP_KINDS = { COMMAND: 'command', EVENT: 'event', READMODEL: 'query' };
11
+
12
+ // "Payment confirmed" / "Payment Confirmed" / "payment_confirmed" are one element.
13
+ const normalize = (title) => String(title ?? '').toLowerCase().replace(/[^a-z0-9]/g, '');
14
+
15
+ export function pascal(title) {
16
+ return String(title ?? '')
17
+ .split(/[^A-Za-z0-9]+/)
18
+ .filter(Boolean)
19
+ .map((w) => w[0].toUpperCase() + w.slice(1))
20
+ .join('');
21
+ }
22
+
23
+ const clean = (title) => String(title ?? '').trim().replace(/\s+/g, ' ');
24
+
25
+ export function buildModel(slices, { context } = {}) {
26
+ const ordered = [...slices].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
27
+ const contextName = clean(context || ordered[0]?.context || 'Default');
28
+ const nodes = new Map(); // `${kind}:${normalized}` → node
29
+ const byElementId = new Map(); // board element id (and linkedId) → node
30
+ const edges = new Map(); // `${from}>${to}` → { from, to }
31
+
32
+ function node(kind, title, slice) {
33
+ const key = `${kind}:${normalize(title)}`;
34
+ let n = nodes.get(key);
35
+ if (!n) {
36
+ n = { key, kind, id: pascal(title), name: clean(title), description: '', fields: [], slices: [], elementIds: [] };
37
+ nodes.set(key, n);
38
+ }
39
+ if (slice && !n.slices.includes(slice)) n.slices.push(slice);
40
+ return n;
41
+ }
42
+
43
+ function mergeFields(n, fields = []) {
44
+ for (const f of fields) if (!n.fields.some((x) => x.name === f.name)) n.fields.push(f);
45
+ }
46
+
47
+ const link = (from, to) => edges.set(`${from.key}>${to.key}`, { from, to });
48
+
49
+ const pending = [];
50
+ for (const slice of ordered) {
51
+ for (const [prop, kind] of ELEMENT_KINDS) {
52
+ for (const el of slice[prop] ?? []) {
53
+ const n = node(kind, el.title, slice.title);
54
+ if (!n.description && el.description) n.description = el.description;
55
+ mergeFields(n, el.fields);
56
+ byElementId.set(el.id, n);
57
+ if (el.linkedId) byElementId.set(el.linkedId, n);
58
+ // The board's ids survive a rename of the title, so a later export can tell a rename from a new element.
59
+ for (const id of [el.id, el.linkedId]) if (id && !n.elementIds.includes(id)) n.elementIds.push(id);
60
+ pending.push([n, el]);
61
+ }
62
+ }
63
+ }
64
+
65
+ // Dependencies are resolved after every element is known, by id first and by title second.
66
+ // A target nothing in the slices defines (e.g. a read model from another context) becomes a stub.
67
+ for (const [n, el] of pending) {
68
+ for (const dep of el.dependencies ?? []) {
69
+ const kind = DEP_KINDS[dep.elementType];
70
+ if (!kind) continue;
71
+ // A message nothing defines (e.g. from another context) becomes a stub. Dependencies on processors
72
+ // and screens never get here: DEP_KINDS doesn't know them, so they are dropped above.
73
+ const target = byElementId.get(dep.id) ?? nodes.get(`${kind}:${normalize(dep.title)}`) ?? node(kind, dep.title);
74
+ if (dep.type === 'INBOUND') link(target, n);
75
+ else link(n, target);
76
+ }
77
+ }
78
+
79
+ const real = [...nodes.values()];
80
+
81
+ const ctxId = pascal(contextName);
82
+ const find = (kind) => real.filter((n) => n.kind === kind);
83
+
84
+ // The flow: one step per element in order of first appearance, edges from the dependencies.
85
+ const inFlow = real;
86
+ const stepId = (n) => `${n.kind}-${n.id}`;
87
+ const steps = inFlow.map((n) => {
88
+ const next = [...edges.values()].filter((e) => e.from === n && inFlow.includes(e.to)).map((e) => stepId(e.to));
89
+ return { id: stepId(n), node: n, next };
90
+ });
91
+
92
+ const chapters = new Set(ordered.map((s) => clean(s.chapter || contextName)));
93
+ const flows = [...chapters].map((chapter) => {
94
+ const inChapter = new Set(ordered.filter((s) => clean(s.chapter || contextName) === chapter).map((s) => s.title));
95
+ const chapterSteps = steps.filter((s) => s.node.slices.some((t) => inChapter.has(t)));
96
+ const ids = new Set(chapterSteps.map((s) => s.id));
97
+ return {
98
+ id: `${pascal(chapter)}Flow`,
99
+ name: chapter,
100
+ steps: chapterSteps.map((s) => ({ ...s, next: s.next.filter((x) => ids.has(x)) })),
101
+ };
102
+ });
103
+
104
+ return {
105
+ domain: { id: ctxId, name: contextName },
106
+ events: find('event'),
107
+ commands: find('command'),
108
+ queries: find('query'),
109
+ flows,
110
+ };
111
+ }
@@ -0,0 +1,82 @@
1
+ // Model → file contents. Everything returned is `{ path, content }`, relative to the catalog root.
2
+
3
+ const JSON_TYPES = {
4
+ string: { type: 'string' },
5
+ text: { type: 'string' },
6
+ uuid: { type: 'string', format: 'uuid' },
7
+ date: { type: 'string', format: 'date' },
8
+ datetime: { type: 'string', format: 'date-time' },
9
+ time: { type: 'string', format: 'time' },
10
+ boolean: { type: 'boolean' },
11
+ int: { type: 'integer' },
12
+ integer: { type: 'integer' },
13
+ long: { type: 'integer' },
14
+ double: { type: 'number' },
15
+ float: { type: 'number' },
16
+ decimal: { type: 'number' },
17
+ number: { type: 'number' },
18
+ };
19
+
20
+ function fieldSchema(f) {
21
+ const base = f.subfields?.length ? objectSchema(f.subfields) : { ...(JSON_TYPES[String(f.type ?? '').toLowerCase()] ?? { type: 'string' }) };
22
+ if (f.example !== undefined && f.example !== '') base.examples = [f.example];
23
+ if (f.description) base.description = f.description;
24
+ return /^(list|multiple|array)$/i.test(f.cardinality ?? '') ? { type: 'array', items: base } : base;
25
+ }
26
+
27
+ function objectSchema(fields) {
28
+ const properties = {};
29
+ for (const f of fields) properties[f.name] = fieldSchema(f);
30
+ const required = fields.filter((f) => !f.optional).map((f) => f.name);
31
+ return { type: 'object', properties, ...(required.length ? { required } : {}) };
32
+ }
33
+
34
+ export function toJsonSchema(fields) {
35
+ return { $schema: 'http://json-schema.org/draft-07/schema#', ...objectSchema(fields) };
36
+ }
37
+
38
+ const KIND_LABEL = { event: 'Event', command: 'Command', query: 'Query' };
39
+ const VERSION = '1.0.0';
40
+
41
+ // EventCatalog's linter requires a summary; fall back to a plain statement when the model has no description.
42
+ const summary = (n, context) => n.description || `${KIND_LABEL[n.kind]} ${n.name} of ${context}.`;
43
+ const usedIn = (n) => (n.slices.length ? `\nUsed in: ${n.slices.join(', ')}.\n` : '\n');
44
+
45
+ // Model → resources in the shape the EventCatalog SDK writes (`writeEvent`, `writeQuery`, …), plus the
46
+ // schema files to attach afterwards. Nothing is written here.
47
+ export function toResources(model, { owners = [] } = {}) {
48
+ const ctx = model.domain.name;
49
+ const owned = owners.length ? { owners } : {};
50
+ const message = (n) => ({
51
+ resource: {
52
+ id: n.id, name: n.name, version: VERSION, summary: summary(n, ctx), ...owned,
53
+ ...(n.fields.length ? { schemaPath: 'schema.json' } : {}),
54
+ markdown: `## Overview\n\n${n.name}.${usedIn(n)}`,
55
+ },
56
+ schema: n.fields.length ? JSON.stringify(toJsonSchema(n.fields), null, 2) + '\n' : null,
57
+ });
58
+ return {
59
+ events: model.events.map(message),
60
+ commands: model.commands.map(message),
61
+ queries: model.queries.map(message),
62
+ flows: model.flows.map((flow) => ({
63
+ resource: {
64
+ id: flow.id, name: flow.name, version: VERSION, summary: `Event model of ${flow.name}.`, ...owned,
65
+ steps: flow.steps.map((s) => ({
66
+ id: s.id,
67
+ title: s.node.name,
68
+ message: { id: s.node.id },
69
+ ...(s.next.length > 1 ? { next_steps: s.next } : s.next.length ? { next_step: s.next[0] } : {}),
70
+ })),
71
+ markdown: `## Overview\n\nGenerated from the event model chapter "${flow.name}".\n`,
72
+ },
73
+ })),
74
+ domain: {
75
+ resource: {
76
+ id: model.domain.id, name: ctx, version: VERSION, summary: `Event model context ${ctx}.`, ...owned,
77
+ ...(model.flows.length ? { flows: model.flows.map((f) => ({ id: f.id })) } : {}),
78
+ markdown: `## Overview\n\nExported from the Eventmodelers context "${ctx}".\n`,
79
+ },
80
+ },
81
+ };
82
+ }
@@ -0,0 +1,63 @@
1
+ // Writes the resources through the EventCatalog SDK, which the *catalog* provides: it is resolved from the
2
+ // target directory's node_modules, never bundled with this CLI, so it always matches the catalog's version.
3
+
4
+ import { execFileSync } from 'child_process';
5
+ import { existsSync, readFileSync } from 'fs';
6
+ import { createRequire } from 'module';
7
+ import { join } from 'path';
8
+
9
+ const PACKAGE = '@eventcatalog/sdk';
10
+
11
+ // An EventCatalog project has an eventcatalog.config.* file and/or depends on @eventcatalog/core.
12
+ export function isCatalog(dir) {
13
+ if (['js', 'mjs', 'cjs', 'ts'].some((ext) => existsSync(join(dir, `eventcatalog.config.${ext}`)))) return true;
14
+ try {
15
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf-8'));
16
+ return Boolean({ ...pkg.dependencies, ...pkg.devDependencies }['@eventcatalog/core']);
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ // The SDK's `utils` factory, or null when the catalog doesn't have the package.
23
+ export async function loadSdk(catalogDir) {
24
+ let file;
25
+ try {
26
+ file = createRequire(join(catalogDir, 'package.json')).resolve(PACKAGE);
27
+ } catch {
28
+ return null;
29
+ }
30
+ const mod = await import(file);
31
+ return typeof mod.default === 'function' ? mod.default : mod.default.default; // CJS entry nests the factory
32
+ }
33
+
34
+ // `npm install` into the catalog — adds the SDK to the *catalog's* package.json, not this CLI's.
35
+ export function installSdk(catalogDir) {
36
+ execFileSync('npm', ['install', PACKAGE], { cwd: catalogDir, stdio: 'inherit' });
37
+ }
38
+
39
+ // [resources key, SDK name part, singular for `<x>HasVersion`, label folder]
40
+ const KINDS = [
41
+ ['events', 'Event', 'event', 'events'], ['commands', 'Command', 'command', 'commands'], ['queries', 'Query', 'query', 'queries'],
42
+ ['flows', 'Flow', 'flow', 'flows'], ['domain', 'Domain', 'domain', 'domains'],
43
+ ];
44
+
45
+ // Existing resources (same id and version) are left alone — they may have been edited. `force` overwrites them.
46
+ // The schema file is attached after the resource (its `schemaPath` is already in the frontmatter).
47
+ export async function writeResources(utils, resources, { force = false } = {}) {
48
+ const written = [];
49
+ const skipped = [];
50
+ for (const [key, Name, singular, folder] of KINDS) {
51
+ const items = key === 'domain' ? [resources.domain] : resources[key];
52
+ for (const { resource, schema } of items) {
53
+ const label = `${folder}/${resource.id}`;
54
+ if (await utils[`${singular}HasVersion`](resource.id, resource.version)) {
55
+ if (!force) { skipped.push(label); continue; }
56
+ }
57
+ await utils[`write${Name}`](resource, { override: force });
58
+ if (schema) await utils[`addSchemaTo${Name}`](resource.id, { schema, fileName: 'schema.json' }, resource.version);
59
+ written.push(label);
60
+ }
61
+ }
62
+ return { written, skipped };
63
+ }
@@ -22,7 +22,7 @@ import { mkdtempSync, writeFileSync } from 'fs';
22
22
  import { tmpdir } from 'os';
23
23
  import { join } from 'path';
24
24
  import { turnTimeoutMs, superviseTurn } from '../shared/build-kit/lib/turn.js';
25
- import { shellQuote, harnessOf, registerMcp } from '../shared/build-kit/lib/mcp-register.js';
25
+ import { shellQuote, harnessOf, harnessFor, registerMcp } from '../shared/build-kit/lib/harnesses/index.js';
26
26
 
27
27
  export { harnessOf, registerMcp };
28
28
 
@@ -31,6 +31,19 @@ export function createModelingExecRunner({ cfg, command: rawCommand, projectDir,
31
31
  throw new Error('no agent command — pass one, e.g. --exec "codex exec --full-auto", or persist it as localAi.exec in .eventmodelers/config.json');
32
32
  }
33
33
  const { command, registered: mcpRegistered } = registerMcp(rawCommand, projectDir, cfg.baseUrl, log);
34
+ // A harness with a session of its own (Cursor's chat) keeps what it read across turns: the process
35
+ // is still one per turn, but its turns share that session — see harnesses/cursor.js. Such a
36
+ // harness takes its turns one at a time (two turns resuming one session would race on it) and
37
+ // the kit preamble is only skipped once a turn has completed in it.
38
+ const session = harnessFor(rawCommand)?.session ?? null;
39
+ let sessionId = null;
40
+ let primed = false;
41
+ let queue = Promise.resolve();
42
+ function withSession(cmd) {
43
+ if (!session) return cmd;
44
+ if (sessionId === null) sessionId = session.start(cmd, { cwd: projectDir, env, log });
45
+ return sessionId ? session.resume(cmd, sessionId) : cmd;
46
+ }
34
47
  const timeoutMs = turnTimeoutMs(cfg);
35
48
  const promptDir = mkdtempSync(join(tmpdir(), 'modeling-exec-'));
36
49
  const env = {
@@ -42,6 +55,13 @@ export function createModelingExecRunner({ cfg, command: rawCommand, projectDir,
42
55
  };
43
56
 
44
57
  function runTurn(text) {
58
+ if (!session) return runTurnNow(text);
59
+ const result = queue.then(() => runTurnNow(text));
60
+ queue = result.catch(() => {});
61
+ return result;
62
+ }
63
+
64
+ function runTurnNow(text) {
45
65
  return new Promise((resolveTurn, rejectTurn) => {
46
66
  const promptFile = join(promptDir, 'prompt.md');
47
67
  writeFileSync(promptFile, text);
@@ -49,7 +69,7 @@ export function createModelingExecRunner({ cfg, command: rawCommand, projectDir,
49
69
  // stdin ignored: some harnesses append a piped stdin to the prompt, and nobody is there to
50
70
  // type into it. stdout is echoed as it comes — the harness owns its output format, there is
51
71
  // no cross-harness stream to condense — and kept as the turn's result.
52
- const proc = spawn(`${command} ${shellQuote(text)}`, {
72
+ const proc = spawn(`${withSession(command)} ${shellQuote(text)}`, {
53
73
  cwd: projectDir,
54
74
  stdio: ['ignore', 'pipe', 'inherit'],
55
75
  shell: true,
@@ -65,6 +85,7 @@ export function createModelingExecRunner({ cfg, command: rawCommand, projectDir,
65
85
  const timeout = turn.timeoutError();
66
86
  if (timeout) return rejectTurn(timeout);
67
87
  if (code !== 0) return rejectTurn(new Error(`exec command exited ${code}`));
88
+ if (sessionId) primed = true;
68
89
  log(`done (${Date.now() - startedAt}ms)`);
69
90
  resolveTurn(out.trim());
70
91
  });
@@ -74,9 +95,11 @@ export function createModelingExecRunner({ cfg, command: rawCommand, projectDir,
74
95
 
75
96
  return {
76
97
  runTurn,
77
- // Nothing to warm: every turn is its own process.
78
- warmUp: async () => {},
98
+ // Only a harness with a session has anything to warm (the loop sends its warm-up turn through runTurn).
99
+ hasSession: Boolean(session),
79
100
  describe: () => rawCommand,
80
101
  mcpRegistered,
102
+ // True once a turn has succeeded in the shared session — until then every turn still carries the preamble.
103
+ get keepsSession() { return primed; },
81
104
  };
82
105
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eventmodelers/cli",
3
- "version": "2.0.9",
3
+ "version": "2.0.11",
4
4
  "description": "Eventmodelers CLI — real-time Claude agent + skills for Claude Code, for any stack (Node, Supabase, Axon, OpenCQRS, UmaDB, Kurrent, or modeling-only)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,6 +12,7 @@
12
12
  "files": [
13
13
  "cli.js",
14
14
  "lib",
15
+ "extensions",
15
16
  "shared",
16
17
  "stacks",
17
18
  "README.md",
@@ -0,0 +1,25 @@
1
+ // OpenAI Codex CLI (`codex exec`).
2
+
3
+ import { shellQuote } from './util.js';
4
+
5
+ export default {
6
+ name: 'codex',
7
+ matches: (words) => words.slice(0, 3).some((w) => w.split('/').pop().replace(/@.*$/, '') === 'codex'),
8
+ preset(loop, env = process.env) {
9
+ const p = { modeling: 'codex exec --full-auto', build: 'codex exec --json --full-auto' };
10
+ // The docker images set this: Codex's own sandbox (landlock) can't start inside a container, and the
11
+ // container already is the sandbox — the flag Codex documents for externally sandboxed environments.
12
+ if (env.EVENTMODELERS_CODEX_NO_SANDBOX === '1') {
13
+ return { modeling: p.modeling.replace('--full-auto', '--dangerously-bypass-approvals-and-sandbox'), build: p.build.replace('--full-auto', '--dangerously-bypass-approvals-and-sandbox') };
14
+ }
15
+ return p;
16
+ },
17
+
18
+ // Codex only loads a project config for a trusted project, so the server rides on the command as flags.
19
+ registerMcp: (command, _projectDir, url) => ({
20
+ // -c values are TOML; env_http_headers names the env var each header is read from.
21
+ command: `${command} -c ${shellQuote(`mcp_servers.eventmodelers.url="${url}"`)} -c ${shellQuote('mcp_servers.eventmodelers.env_http_headers={x-token="EVENTMODELERS_TOKEN",x-agent-id="EVENTMODELERS_AGENT_ID"}')}`,
22
+ file: null,
23
+ manual: null,
24
+ }),
25
+ };
@@ -0,0 +1,62 @@
1
+ // Cursor's CLI (`cursor-agent`, or plain `agent` on current installs).
2
+ //
3
+ // The one harness with a session of its own. The CLI takes one prompt per process, but it can keep
4
+ // a chat across runs: `create-chat` once, `--resume <id>` on every turn. The process is still new
5
+ // each turn; what carries over is the conversation — the kit's CLAUDE.md and the MCP tool set it
6
+ // has already read — so only the first turn pays for them.
7
+
8
+ import { execFileSync, spawnSync } from 'child_process';
9
+ import { join } from 'path';
10
+ import { mergeJsonConfig } from './util.js';
11
+
12
+ const binName = (word) => word.split('/').pop().replace(/@.*$/, '');
13
+
14
+ // Whichever Cursor binary is on PATH; with neither, `cursor-agent` so the failure names a real binary.
15
+ export function cursorBinary() {
16
+ for (const bin of ['cursor-agent', 'agent']) {
17
+ try { execFileSync('which', [bin], { stdio: 'ignore' }); return bin; } catch {}
18
+ }
19
+ return 'cursor-agent';
20
+ }
21
+
22
+ export default {
23
+ name: 'cursor',
24
+ // `agent` is far too generic a word to match anywhere but first.
25
+ matches: (words) => binName(words[0]) === 'agent' || words.slice(0, 3).some((w) => ['cursor-agent', 'cursor'].includes(binName(w))),
26
+ preset() {
27
+ const bin = cursorBinary();
28
+ return {
29
+ modeling: `${bin} -p --output-format text --approve-mcps --force --trust`,
30
+ build: `${bin} -p --output-format stream-json --approve-mcps --force --trust`,
31
+ };
32
+ },
33
+ modelFlag: '--model', // `-m` is an unknown option to this CLI
34
+
35
+ // The same project .cursor/mcp.json the editor reads; ${env:NAME} is resolved from the env the
36
+ // runner hands it. --approve-mcps in the command is what lets a headless run load it unprompted.
37
+ registerMcp(command, projectDir, url) {
38
+ const entry = { url, headers: { 'x-token': '${env:EVENTMODELERS_TOKEN}', 'x-agent-id': '${env:EVENTMODELERS_AGENT_ID}' } };
39
+ const ok = mergeJsonConfig(join(projectDir, '.cursor', 'mcp.json'), (config) => {
40
+ config.mcpServers = config.mcpServers || {};
41
+ config.mcpServers.eventmodelers = entry;
42
+ });
43
+ return { command, file: ok ? '.cursor/mcp.json' : null, manual: ok ? null : `add mcpServers.eventmodelers = ${JSON.stringify(entry)} to .cursor/mcp.json` };
44
+ },
45
+
46
+ // Turns share one chat. start() makes it (returns its id, or '' when it could not); resume() puts
47
+ // it on a command — right after the binary, ahead of the flags and the prompt.
48
+ session: {
49
+ start(command, { cwd, env, log }) {
50
+ const bin = command.trim().split(/\s+/)[0];
51
+ const r = spawnSync(bin, ['create-chat'], { cwd, encoding: 'utf8', env });
52
+ const id = String(r.stdout || '').trim().split(/\s+/).pop();
53
+ if (r.status !== 0 || !id) {
54
+ log(`cursor: could not create a chat (${String(r.stderr || '').trim() || `exit ${r.status}`}) — running without resume, every turn starts cold`);
55
+ return '';
56
+ }
57
+ log(`cursor: resuming chat ${id} on every turn`);
58
+ return id;
59
+ },
60
+ resume: (command, id) => command.replace(/^(\s*\S+)/, `$1 --resume ${id}`),
61
+ },
62
+ };
@@ -0,0 +1,19 @@
1
+ // Gemini CLI (`gemini -p`).
2
+
3
+ import { join } from 'path';
4
+ import { mergeJsonConfig } from './util.js';
5
+
6
+ export default {
7
+ name: 'gemini',
8
+ matches: (words) => words.slice(0, 3).some((w) => w.split('/').pop().replace(/@.*$/, '') === 'gemini'),
9
+ preset: () => ({ modeling: 'gemini --yolo -p', build: 'gemini --yolo --output-format stream-json -p' }),
10
+
11
+ registerMcp(command, projectDir, url) {
12
+ const entry = { httpUrl: url, headers: { 'x-token': '$EVENTMODELERS_TOKEN', 'x-agent-id': '$EVENTMODELERS_AGENT_ID' } };
13
+ const ok = mergeJsonConfig(join(projectDir, '.gemini', 'settings.json'), (config) => {
14
+ config.mcpServers = config.mcpServers || {};
15
+ config.mcpServers.eventmodelers = entry;
16
+ });
17
+ return { command, file: ok ? '.gemini/settings.json' : null, manual: ok ? null : `add mcpServers.eventmodelers = ${JSON.stringify(entry)} to .gemini/settings.json` };
18
+ },
19
+ };
@@ -0,0 +1,28 @@
1
+ // Hermes (`hermes chat`).
2
+
3
+ import { existsSync, readFileSync } from 'fs';
4
+ import { homedir } from 'os';
5
+ import { join } from 'path';
6
+
7
+ export default {
8
+ name: 'hermes',
9
+ matches: (words) => words.slice(0, 3).some((w) => w.split('/').pop().replace(/@.*$/, '') === 'hermes'),
10
+ // -q takes the prompt, so the preset ends with it (the --model flag is inserted before it, see withModel).
11
+ // -Q is the plain-text mode a modeling turn reads its answer from; --format stream-json is the build kit's.
12
+ preset: () => ({ modeling: 'hermes chat --yolo -Q -q', build: 'hermes chat --yolo --format stream-json -q' }),
13
+ withModel: (command, flag) => (command.endsWith(' -q') ? `${command.slice(0, -3)} ${flag} -q` : `${command} ${flag}`),
14
+
15
+ // Hermes reads MCP servers from ~/.hermes/config.yaml only (there is no project-level file), so
16
+ // this never writes it — a user-wide file is not ours to rewrite. It reports the server as
17
+ // registered when the file already names it (the docker images ship it), and says what to add otherwise.
18
+ registerMcp(command, _projectDir, url) {
19
+ const file = join(process.env.HERMES_HOME || join(homedir(), '.hermes'), 'config.yaml');
20
+ const has = existsSync(file) && /^\s+eventmodelers:/m.test(readFileSync(file, 'utf8'));
21
+ return {
22
+ command,
23
+ file: null,
24
+ reason: '', // not a JSON file — there is nothing to say about its format
25
+ manual: has ? null : `add to ${file}:\nmcp_servers:\n eventmodelers:\n url: "${url}"\n headers:\n x-token: "\${EVENTMODELERS_TOKEN}"`,
26
+ };
27
+ },
28
+ };
@@ -0,0 +1,66 @@
1
+ // The agent harnesses `run --exec` / `run --agent` can hand a turn to, one module each — everything
2
+ // a harness needs (its headless command, how to recognise it, how the eventmodelers MCP server gets
3
+ // registered with it, its model flag, any session of its own) lives in its file. Claude is not here:
4
+ // it has a dedicated runner. Adding a harness is one module and one line in HARNESSES.
5
+ //
6
+ // A module exports: name, matches(words), preset(loop?) -> {modeling, build}, registerMcp(command,
7
+ // projectDir, url) -> {command, file, manual, reason?}, and optionally modelFlag (default `-m`),
8
+ // withModel(command, flag) and session {start, resume}.
9
+
10
+ import opencode from './opencode.js';
11
+ import codex from './codex.js';
12
+ import gemini from './gemini.js';
13
+ import hermes from './hermes.js';
14
+ import cursor from './cursor.js';
15
+ import { shellQuote } from './util.js';
16
+
17
+ export { shellQuote };
18
+ export { opencodeSupportsStandalone, setOpencodeStandaloneSupport } from './opencode.js';
19
+
20
+ const HARNESSES = [opencode, codex, gemini, hermes, cursor];
21
+
22
+ // Every name `--agent` takes, claude (the default runner, no command) first.
23
+ export const AGENT_NAMES = ['claude', ...HARNESSES.map((h) => h.name)];
24
+
25
+ export function harnessByName(name) {
26
+ return HARNESSES.find((h) => h.name === name) ?? null;
27
+ }
28
+
29
+ // Which known harness a command runs — the first words naming one, so `npx @openai/codex exec`
30
+ // and `/opt/bin/opencode run` are recognised as well as the bare names.
31
+ export function harnessFor(command) {
32
+ const words = String(command).trim().split(/\s+/);
33
+ return HARNESSES.find((h) => h.matches(words)) ?? null;
34
+ }
35
+
36
+ export function harnessOf(command) {
37
+ return harnessFor(command)?.name ?? null;
38
+ }
39
+
40
+ // The headless command --agent <name> stands for, for the `modeling` or `build` loop.
41
+ export function presetCommand(name, loop) {
42
+ return harnessByName(name)?.preset(loop)[loop] ?? null;
43
+ }
44
+
45
+ // `--model <id>` on a preset command, spelled the way that harness wants it.
46
+ export function withModel(command, id) {
47
+ const h = harnessFor(command);
48
+ const flag = `${h?.modelFlag ?? '-m'} ${shellQuote(id)}`;
49
+ return h?.withModel ? h.withModel(command, flag) : `${command} ${flag}`;
50
+ }
51
+
52
+ // Registers the MCP server for the command's harness. Returns the command to run with it, and
53
+ // whether the server is now registered — false for an unknown command or a config it could not
54
+ // update, where the turn still has to connect the way the kit says to.
55
+ export function registerMcp(command, projectDir, baseUrl, log) {
56
+ const url = `${baseUrl}/mcp`;
57
+ const harness = harnessFor(command);
58
+ if (!harness) {
59
+ log(`MCP: unknown agent command — register the eventmodelers MCP server with it yourself: ${url}, header x-token from $EVENTMODELERS_TOKEN (and x-agent-id from $EVENTMODELERS_AGENT_ID)`);
60
+ return { command, registered: false };
61
+ }
62
+ const reg = harness.registerMcp(command, projectDir, url);
63
+ if (reg.manual) log(`MCP: could not update the ${harness.name} config${reg.reason ?? ' (not plain JSON)'} — ${reg.manual}`);
64
+ else log(`MCP: eventmodelers registered for ${harness.name}${reg.file ? ` in ${reg.file}` : ' via -c flags'}`);
65
+ return { command: reg.command, registered: !reg.manual };
66
+ }
@@ -0,0 +1,51 @@
1
+ // OpenCode (`opencode run`).
2
+
3
+ import { spawnSync } from 'child_process';
4
+ import { join } from 'path';
5
+ import { mergeJsonConfig } from './util.js';
6
+
7
+ // Older OpenCode releases have no `run --standalone`; passing it makes `opencode run` print its
8
+ // usage and exit 1, so every turn fails. Ask the installed binary once. Overridable for tests.
9
+ let standaloneSupported = null;
10
+ export function opencodeSupportsStandalone() {
11
+ if (standaloneSupported === null) {
12
+ const r = spawnSync('opencode', ['run', '--help'], { encoding: 'utf8' });
13
+ standaloneSupported = /--standalone\b/.test(`${r.stdout || ''}${r.stderr || ''}`);
14
+ }
15
+ return standaloneSupported;
16
+ }
17
+ export function setOpencodeStandaloneSupport(value) { standaloneSupported = value; }
18
+
19
+ export default {
20
+ name: 'opencode',
21
+ matches: (words) => words.slice(0, 3).some((w) => binName(w) === 'opencode'),
22
+ // --standalone: a plain `opencode run` hands the turn to opencode's shared background service,
23
+ // started with some other env — so the MCP header's {env:EVENTMODELERS_TOKEN} resolves to nothing
24
+ // there and every eventmodelers tool fails to authenticate. A private server inherits ours.
25
+ preset: () => ({ modeling: 'opencode run --standalone --auto', build: 'opencode run --standalone --auto --format json' }),
26
+
27
+ registerMcp(command, projectDir, url) {
28
+ const entry = {
29
+ type: 'remote',
30
+ url,
31
+ headers: { 'x-token': '{env:EVENTMODELERS_TOKEN}', 'x-agent-id': '{env:EVENTMODELERS_AGENT_ID}' },
32
+ };
33
+ const ok = mergeJsonConfig(join(projectDir, 'opencode.json'), (config) => {
34
+ // OpenCode 2 nests servers under mcp.servers and still reads the older flat mcp.<name>;
35
+ // follow whichever form the file already uses.
36
+ config.mcp = config.mcp || {};
37
+ if (config.mcp.servers) config.mcp.servers.eventmodelers = entry;
38
+ else config.mcp.eventmodelers = entry;
39
+ });
40
+ // A plain `opencode run` hands the turn to opencode's shared background service, which was
41
+ // started with some other env: {env:EVENTMODELERS_TOKEN} resolves to nothing there and the
42
+ // server refuses every call. --standalone runs a private server that inherits this one's env.
43
+ // A binary without the flag runs the turn in-process already, so the flag is dropped, not added.
44
+ const supported = opencodeSupportsStandalone();
45
+ const standalone = !supported ? command.replace(/\s--standalone\b/, '')
46
+ : /\s--(standalone|server)\b/.test(` ${command}`) ? command : `${command} --standalone`;
47
+ return { command: standalone, file: ok ? 'opencode.json' : null, manual: ok ? null : `add mcp.eventmodelers = ${JSON.stringify(entry)} to opencode.json` };
48
+ },
49
+ };
50
+
51
+ function binName(word) { return word.split('/').pop().replace(/@.*$/, ''); }
@@ -0,0 +1,24 @@
1
+ // Helpers the harness modules share.
2
+
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
4
+ import { dirname } from 'path';
5
+
6
+ // POSIX single-quote escaping: close, insert an escaped quote, reopen. A turn is multi-line
7
+ // text with backticks and $ in it, so it cannot go in unquoted.
8
+ export function shellQuote(s) {
9
+ return `'${String(s).replace(/'/g, `'\\''`)}'`;
10
+ }
11
+
12
+ // Adds or refreshes one key in a project JSON config, leaving everything else in it alone. A file
13
+ // that isn't plain JSON (a JSONC one with comments) is not rewritten — returns false instead, and
14
+ // the caller says what to add by hand.
15
+ export function mergeJsonConfig(path, apply) {
16
+ let config = {};
17
+ if (existsSync(path)) {
18
+ try { config = JSON.parse(readFileSync(path, 'utf8')); } catch { return false; }
19
+ }
20
+ apply(config);
21
+ mkdirSync(dirname(path), { recursive: true });
22
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`);
23
+ return true;
24
+ }