@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.
package/README.md CHANGED
@@ -162,7 +162,7 @@ npx @eventmodelers/cli run # start the agent loop (ralp
162
162
  npx @eventmodelers/cli run --all # every build kit in the project at once (this directory and up to two levels below, e.g. a scaffolded app's root and web/), prefixed output, Ctrl+c stops all; other flags (--agent, --handover, …) go to each
163
163
  npx @eventmodelers/cli run --all --modeling # the same, plus a modeling agent running alongside the build kits
164
164
  npx @eventmodelers/cli run --local-ai [target] # board work only via a local/self-hosted model (ralph-local-ai.js) — does NOT build slices; build locally with --exec
165
- npx @eventmodelers/cli run --agent <name> # pick the agent: claude (default), opencode, codex, gemini — bare --agent lists them with their exact commands
165
+ npx @eventmodelers/cli run --agent <name> # pick the agent: claude (default), opencode, codex, gemini, hermes, cursor — bare --agent lists them with their exact commands
166
166
  npx @eventmodelers/cli run --agent <name> --model <id> # …on a given model: -m for opencode/codex/gemini, --model for Claude, LOCAL_AI_MODEL for --local-ai
167
167
  npx @eventmodelers/cli run --exec "<command>" # manual fallback for any other harness (ralph-exec.js) — agent tracing needs its JSON flag, e.g. "codex exec --json --full-auto"
168
168
  npx @eventmodelers/cli run --local # skip platform config/credential lookup entirely — local-only, no board sync
@@ -243,6 +243,7 @@ registered for it (`opencode.json`, `.gemini/settings.json`, or `-c` flags for C
243
243
  npx @eventmodelers/cli run --standalone --agent opencode # = --exec "opencode run --standalone --auto"
244
244
  npx @eventmodelers/cli run --modeling --agent codex # = --exec "codex exec --full-auto"
245
245
  npx @eventmodelers/cli run --modeling --agent gemini # = --exec "gemini --yolo -p"
246
+ npx @eventmodelers/cli run --modeling --agent cursor # = --exec "agent -p --output-format text --approve-mcps --force --trust"; creates one Cursor chat and resumes it every turn, so only the first turn reads the kit
246
247
  npx @eventmodelers/cli run --modeling --agent claude # the default, spelled out
247
248
  npx @eventmodelers/cli run --worker --agent opencode --model opencode/big-pickle # pick the model (a free OpenCode Zen one here)
248
249
  npx @eventmodelers/cli run --agent # list the agents and the command each runs
@@ -694,6 +695,7 @@ Flow: `Ready → Planned → InProgress → Waiting → InProgress → Done`. Pr
694
695
  - **The platform must know the `Ready` / `Waiting` statuses.**
695
696
  - A kit declares what it claims in `lib/kit.json` — `{ "pickupStatus": "Waiting" }`; absent means `Planned`.
696
697
  - `run --status-trigger <status>` overrides that pickup status for a single run (e.g. `--status-trigger Ready`); nothing is saved.
698
+ - `run --feature <name>` picks up only the slices that belong to that feature (the names set per slice on the board, matched case-insensitively); without it every slice in the pickup status is built. Slices without a feature wait for a run without the flag. Nothing is saved.
697
699
 
698
700
  The contract itself is the shared `ui-contract` skill's job (see above); it also owns the rule for which status a slice ends at.
699
701
 
package/cli.js CHANGED
@@ -44,6 +44,7 @@ import { prunableEvents, pruneLog, rememberPruneAnswer, shouldOfferPrune } from
44
44
  import { fold } from './shared/build-kit/lib/slice-state.js';
45
45
  import { filterEvents, baselineIds, formatEvent, formatPayload, sliceTitles, summarize } from './shared/build-kit/lib/events-view.js';
46
46
  import { stableGlobalAgentId } from './lib/global-agent-id.js';
47
+ import { AGENT_NAMES, presetCommand, withModel } from './shared/build-kit/lib/harnesses/index.js';
47
48
  import { ensureLearningsGitRules } from './shared/build-kit/lib/ralph.js';
48
49
  import { createCallTracker, heldPromptsRequest, journalPromptAction, shouldJournalCall, journalPromptFinished, journalPromptReceived, recoverOwnPrompts } from './shared/build-kit/lib/prompt-journal.js';
49
50
  import { INFO_KINDS, infoComment, recordInfo, recordTurnInfos } from './shared/build-kit/lib/slice-info.js';
@@ -95,23 +96,13 @@ function resolveLocalAiTarget(opts) {
95
96
  // build kit wants the JSON mode, the only one that reports the usage agent tracing records.
96
97
  // claude has no command — it is the default runner, named so it can be picked explicitly.
97
98
  // --exec stays the fallback for any harness or flag set not listed here.
98
- const AGENT_PRESETS = {
99
- claude: null,
100
- // --standalone: a plain `opencode run` hands the turn to opencode's shared background service,
101
- // started with some other env — so the MCP header's {env:EVENTMODELERS_TOKEN} resolves to nothing
102
- // there and every eventmodelers tool fails to authenticate. A private server inherits ours.
103
- opencode: { modeling: 'opencode run --standalone --auto', build: 'opencode run --standalone --auto --format json' },
104
- codex: { modeling: 'codex exec --full-auto', build: 'codex exec --json --full-auto' },
105
- gemini: { modeling: 'gemini --yolo -p', build: 'gemini --yolo --output-format stream-json -p' },
106
- // -q takes the prompt, so the preset ends with it (the --model flag is inserted before it, below).
107
- // -Q is the plain-text mode a modeling turn reads its answer from; --format stream-json is the build kit's.
108
- hermes: { modeling: 'hermes chat --yolo -Q -q', build: 'hermes chat --yolo --format stream-json -q' },
109
- };
99
+ // The commands live with their harness, in shared/build-kit/lib/harnesses/<name>.js.
110
100
 
111
101
  function printAgentPresets() {
112
102
  console.log('Supported agents for run --agent <name>:\n');
113
- for (const [name, preset] of Object.entries(AGENT_PRESETS)) {
114
- console.log(` ${name.padEnd(9)}${preset ? `modeling: ${preset.modeling}\n ${''.padEnd(9)}build: ${preset.build}` : 'the default runner (warm Claude process / ralph-claude.js)'}`);
103
+ for (const name of AGENT_NAMES) {
104
+ const modeling = presetCommand(name, 'modeling');
105
+ console.log(` ${name.padEnd(9)}${modeling ? `modeling: ${modeling}\n ${''.padEnd(9)}build: ${presetCommand(name, 'build')}` : 'the default runner (warm Claude process / ralph-claude.js)'}`);
115
106
  }
116
107
  console.log('\nAny other harness: run --exec "<command>".');
117
108
  console.log(`A local or self-hosted model: run --local-ai [${LOCAL_AI_TARGETS.join('|')}].`);
@@ -126,8 +117,8 @@ function resolveAgentPreset(opts, loop) {
126
117
  process.exit(0);
127
118
  }
128
119
  const name = String(opts.agent).toLowerCase();
129
- if (!(name in AGENT_PRESETS)) {
130
- console.error(`❌ Unknown --agent "${opts.agent}" — one of: ${Object.keys(AGENT_PRESETS).join(', ')}.`);
120
+ if (!AGENT_NAMES.includes(name)) {
121
+ console.error(`❌ Unknown --agent "${opts.agent}" — one of: ${AGENT_NAMES.join(', ')}.`);
131
122
  console.error(' Any other harness works via --exec "<command>".');
132
123
  console.error(` A local or self-hosted model works via --local-ai [${LOCAL_AI_TARGETS.join('|')}].`);
133
124
  process.exit(1);
@@ -140,13 +131,7 @@ function resolveAgentPreset(opts, loop) {
140
131
  console.error('❌ --agent is mutually exclusive with --local-ai — pick one runner.');
141
132
  process.exit(1);
142
133
  }
143
- const cmd = AGENT_PRESETS[name]?.[loop] ?? null;
144
- // The docker images set this: Codex's own sandbox (landlock) can't start inside a container, and the
145
- // container already is the sandbox — the flag Codex documents for externally sandboxed environments.
146
- if (name === 'codex' && cmd && process.env.EVENTMODELERS_CODEX_NO_SANDBOX === '1') {
147
- return cmd.replace('--full-auto', '--dangerously-bypass-approvals-and-sandbox');
148
- }
149
- return cmd;
134
+ return presetCommand(name, loop);
150
135
  }
151
136
 
152
137
  const STACKS = {
@@ -2167,7 +2152,7 @@ async function runModeling(kitDir, projectDir, { codeDir = null, verbose = false
2167
2152
  if (localRunner) return body;
2168
2153
  // Every exec turn is a fresh process — a new session that has read nothing, so each one gets
2169
2154
  // the header its first turn would.
2170
- if (!firstTurn && !execRunner) return body;
2155
+ if (!firstTurn && !(execRunner && !execRunner.keepsSession)) return body;
2171
2156
  firstTurn = false;
2172
2157
  return `MODE=modeling token=$EVENTMODELERS_TOKEN org=${cfg.organizationId} baseUrl=${cfg.baseUrl} standalone=${standalone ? 'on' : 'off'}${standalone ? ` max_agents=${maxAgents}` : ''} subagent_model=${subagentModel}\n\n${QUESTIONING_RULE}${execRunner?.mcpRegistered ? EXEC_CONNECTED : ''}Read .agent-modeling-kit/CLAUDE.md now and follow it for every prompt in this session — it's a one-time read; don't re-read it on later turns.\n\n${body}`;
2173
2158
  }
@@ -2414,7 +2399,8 @@ async function runModeling(kitDir, projectDir, { codeDir = null, verbose = false
2414
2399
  if (!standalone) return (warmUp = Promise.resolve());
2415
2400
  log('warm-up: connecting and listing chapters before the first turn (chapters are read on first use)');
2416
2401
  warmingUp = true;
2417
- warmUp = sendTurn(buildWarmUpTurn())
2402
+ // An exec runner with a session of its own (Cursor's chat) is warmed through that session.
2403
+ warmUp = (execRunner ? execRunner.runTurn(buildWarmUpTurn()) : sendTurn(buildWarmUpTurn()))
2418
2404
  .then((result) => log(`warm-up done — ${oneLine(result, 200) || 'session ready'}`))
2419
2405
  .catch((err) => {
2420
2406
  // Not fatal: put the session header back so the next real turn carries the
@@ -2460,10 +2446,10 @@ async function runModeling(kitDir, projectDir, { codeDir = null, verbose = false
2460
2446
  // Both warm-ups are the same bet — pay the session's fixed setup cost before a turn arrives
2461
2447
  // rather than making whoever sends the first prompt wait for it. For a local model that cost
2462
2448
  // is the MCP tool set; for Claude it is reading CLAUDE.md and running /connect.
2463
- // An exec runner has nothing to warm: every turn is its own process, so a warm-up would connect
2464
- // a session that exits right after.
2449
+ // An exec runner without a session has nothing to warm: every turn is its own process, so a warm-up
2450
+ // would connect one that exits right after. One with a session (Cursor's chat) does — the chat outlives the process.
2465
2451
  if (localRunner) localRunner.warmUp().catch((err) => log(`local-ai warm-up failed (the first turn will retry): ${err.message}`));
2466
- else if (!execRunner) warmUpSession();
2452
+ else if (!execRunner || execRunner.hasSession) warmUpSession();
2467
2453
 
2468
2454
  async function getRealtimeToken() {
2469
2455
  const res = await fetch(`${cfg.baseUrl}/api/org/${cfg.organizationId}/prompts/realtime-token`, {
@@ -3319,7 +3305,7 @@ program
3319
3305
  // dir: a modeling run falls back to the global install (see ensureGlobalKit) rather than
3320
3306
  // requiring one here, and the build-kit branch reports a better-targeted error of its own
3321
3307
  // than this generic gate can.
3322
- const NO_INIT_REQUIRED = new Set(['init', 'scaffold', 'init-templates', 'init-config', 'stacks', 'status', 'config', 'uninstall', 'fetch', 'activate-context', 'set-slice-status', 'release-notes', 'run', 'listen', 'events', 'replay', 'bridge']);
3308
+ const NO_INIT_REQUIRED = new Set(['init', 'scaffold', 'init-templates', 'init-config', 'stacks', 'status', 'config', 'uninstall', 'fetch', 'export', 'activate-context', 'set-slice-status', 'release-notes', 'run', 'listen', 'events', 'replay', 'bridge']);
3323
3309
 
3324
3310
  // Commands that install or refresh the kit themselves — a "your kit is outdated" hint in
3325
3311
  // front of them would only be noise.
@@ -3813,8 +3799,8 @@ credentialFlags(program
3813
3799
  .description('Start the agent loop from the installed kit dir — build-kit stacks: ralph-claude.js (default); modeling-kit: --modeling, or --standalone, which needs no install at all')
3814
3800
  .option('--local-ai [target]', `Drive the loop with a local (or self-hosted) model instead of the default Claude runner: a build kit runs it via ralph-local-ai.js, and --modeling/--standalone via lib/modeling-local-ai.js (board tools over MCP, but no skills and no subagent fan-out — those are Claude Code features). Optional target preset picks the URL and wire dialect: ${LOCAL_AI_TARGETS.join(', ')} — bare --local-ai means ollama. Anything OpenAI-compatible (vLLM, LM Studio, llama.cpp, TGI) works by pointing LOCAL_AI_URL at it; see LOCAL_AI_* in the docs. Board work only: --local-ai never builds a Planned slice (no file, shell or git tools). To build code with a local model, use --exec with a coding harness pointed at it, e.g. "codex exec --oss --full-auto". Claude remains the default when this flag is absent.`)
3815
3801
  .addOption(new Option('--bash').hideHelp())
3816
- .option('--agent [name]', `Pick the agent that does the work by name: ${Object.keys(AGENT_PRESETS).join(', ')}. claude is the default runner; any other is a shortcut for --exec with that harness's headless command filled in — plain-text output for --modeling/--standalone/--worker, JSON output (for agent tracing) for a build kit. Bare --agent lists the agents and the exact command each runs, then stops. Mutually exclusive with --exec and --local-ai.`)
3817
- .option('--model <id>', 'The model the agent runs on, for this run only: `-m <id>` on the --agent opencode/codex/gemini command (e.g. opencode/big-pickle, gpt-5-codex, gemini-2.5-pro), `--model <id>` for Claude (overriding `model` from config), LOCAL_AI_MODEL for --local-ai. Not with --exec — put the harness\'s own model flag in the command there.')
3802
+ .option('--agent [name]', `Pick the agent that does the work by name: ${AGENT_NAMES.join(', ')}. claude is the default runner; any other is a shortcut for --exec with that harness's headless command filled in — plain-text output for --modeling/--standalone/--worker, JSON output (for agent tracing) for a build kit. Bare --agent lists the agents and the exact command each runs, then stops. Mutually exclusive with --exec and --local-ai.`)
3803
+ .option('--model <id>', 'The model the agent runs on, for this run only: `-m <id>` on the --agent opencode/codex/gemini command (`--model <id>` for cursor) (e.g. opencode/big-pickle, gpt-5-codex, gemini-2.5-pro), `--model <id>` for Claude (overriding `model` from config), LOCAL_AI_MODEL for --local-ai. Not with --exec — put the harness\'s own model flag in the command there.')
3818
3804
  .option('--exec [command]', 'Fallback for any harness --agent does not cover, or other flags than its preset. Hand each prompt to an external agent command instead of the default Claude runner — for agentic harnesses that bring their own tool loop, e.g. "codex exec --full-auto", "gemini --yolo -p" or "opencode run". One process per prompt; the prompt is appended as a quoted argument and also written to the file named by RALPH_PROMPT_FILE. Bare --exec uses localAi.exec from .eventmodelers/config.json. Claude remains the default when this flag is absent. A build kit runs it via ralph-exec.js; there, with agent tracing on, include the harness\'s JSON output flag (codex exec --json, opencode run --format json, gemini --output-format stream-json) — without it no slice cost is recorded. With --modeling/--standalone/--worker it is how any agent takes board prompts and chats: the loop claims each prompt or chat message and runs the command once for it. Keep the harness in its plain-text output mode there — stdout is the turn\'s answer — the eventmodelers MCP server is registered for it on every run (opencode.json, .gemini/settings.json, or -c flags for codex; any other command logs what to register by hand), authenticating with $EVENTMODELERS_TOKEN from the env the command inherits, and `init-agents --hosts <harness>` installs the skills for a harness that does not read .claude/skills. No subagents: self-directed work is done inline.')
3819
3805
  .option('--all', 'Run every build kit in this project at once — the one in this directory and any up to two levels below it (a scaffolded app has `.build-kit` at the root and in `web/`, so this works from the app or from the folder holding it) — each from its own directory, output prefixed with the directory, Ctrl+c stops them all. Every other flag is passed on to each run. Not interactive: the kits\' credentials must already be configured (except with --force, which asks once per kit up front, here, before the runs start). With --modeling, the modeling agent runs alongside the build kits. Not with --standalone, --worker, --global, --local or --id (one id cannot belong to several agents).')
3820
3806
  .option('--modeling', 'Connect a modeling agent to the board: it takes prompts and chat messages and hands each to an AI — by default one Claude process kept warm across prompts, for low-latency voice/live use; --agent (or --local-ai, or --exec as the manual fallback) picks another. Runs from a modeling-kit install in this directory, or from the global install (~/.eventmodelers/kit) when there is none. Built into the CLI, not a per-project file.')
@@ -3830,6 +3816,7 @@ credentialFlags(program
3830
3816
  .option('--browser', 'Give the locked-down modeling agent a browser: the Playwright MCP server (npx @playwright/mcp) next to the eventmodelers one, for discover-storyboard. Off by default because a browser can reach any site, which the lockdown otherwise rules out. Default Claude runner only; --dangerously-skip-permissions already loads every MCP server the machine configures.')
3831
3817
  .option('--handover', 'Backend build kit: for this run only, finish slices that have a UI at `Waiting` instead of `Done` (same as the project setting `handover: true`, which `init --handover` / `config --handover on` save — this flag writes nothing). Without it the project setting applies; without both, slices end at `Done`.')
3832
3818
  .option('--status-trigger <status>', 'Build kit: the slice status the agent picks work up from, for this run only (e.g. Ready, Planned, Waiting). Defaults to the kit\'s own pickup status — `Planned` for backend kits, `Handover` for the UI kits. Nothing is written to disk.')
3819
+ .option('--feature <name>', 'Build kit: only pick up slices that belong to this feature (the free-text names set per slice on the board, matched case-insensitively), for this run only. Other slices wait for a run without it or with their own feature. Nothing is written to disk.')
3833
3820
  .option('--sync', 'Build kit: share this agent\'s event log with the board — events that name a slice are uploaded constantly (POST …/buildlog) for a live dashboard. The platform keeps one iteration per slice, 1000 entries at most. A no-op for --modeling/--standalone, and with --local.')
3834
3821
  .option('--verbose', 'Log every tool call\'s full input (commands, skill args, file paths) and assistant reasoning text. Default is condensed, high-level per-step logging only.')
3835
3822
  .option('--id <id>', 'Pin the agent id this run identifies itself with on the platform. Without it an agent keeps one id: a project install per project, a --global/--standalone run per board and repo (the directory it is started in) — the same agent on every restart, with its chat, its star on the board and the prompts it holds. If another live process already runs under that id, this one gets a fresh id instead (two agents must not share a heartbeat row). Pass --id when a supervisor already knows the id you want, or for a second agent on purpose; it is used as given, with no such check. Per-run only: nothing is written to disk.')
@@ -3902,9 +3889,7 @@ credentialFlags(program
3902
3889
  process.exit(1);
3903
3890
  }
3904
3891
  if (model && agentExec) {
3905
- const modelFlag = `-m '${model.replace(/'/g, `'\\''`)}'`;
3906
- // A trailing -q takes the prompt as its value, so the model flag has to come before it.
3907
- opts.exec = agentExec.endsWith(' -q') ? `${agentExec.slice(0, -3)}${modelFlag} -q` : `${agentExec} ${modelFlag}`;
3892
+ opts.exec = withModel(agentExec, model);
3908
3893
  }
3909
3894
  else if (model && resolveLocalAiTarget(opts) !== null) process.env.LOCAL_AI_MODEL = model;
3910
3895
  // The default Claude runner: the build kit's ralph-claude.js reads RALPH_MODEL from the env it
@@ -4122,7 +4107,7 @@ credentialFlags(program
4122
4107
  // path with no stream-json parsing to gate. RALPH_LOCAL is read by ralph.js's startRalph to force
4123
4108
  // the local-only branch even when .eventmodelers/config.json has valid credentials.
4124
4109
  // RALPH_AGENT_ID/RALPH_AGENT_NAME (--id/--name) are read there too.
4125
- execSync(cmd, { cwd: kitDir, stdio: 'inherit', env: { ...process.env, RALPH_VERBOSE: opts.verbose ? '1' : '', RALPH_SYNC: opts.sync ? '1' : '', RALPH_LOCAL: opts.local ? '1' : '', RALPH_HANDOVER: opts.handover ? '1' : '', RALPH_STATUS_TRIGGER: opts.statusTrigger ? String(opts.statusTrigger).trim() : '', RALPH_AGENT_ID: identity.agentId ?? '', RALPH_AGENT_NAME: identity.agentName ?? '', ...(typeof localAiTarget === 'string' ? { LOCAL_AI_TARGET: localAiTarget } : {}), ...(typeof opts.exec === 'string' ? { RALPH_EXEC_CMD: opts.exec } : {}) } });
4110
+ execSync(cmd, { cwd: kitDir, stdio: 'inherit', env: { ...process.env, RALPH_VERBOSE: opts.verbose ? '1' : '', RALPH_SYNC: opts.sync ? '1' : '', RALPH_LOCAL: opts.local ? '1' : '', RALPH_HANDOVER: opts.handover ? '1' : '', RALPH_STATUS_TRIGGER: opts.statusTrigger ? String(opts.statusTrigger).trim() : '', RALPH_FEATURE: opts.feature ? String(opts.feature).trim() : '', RALPH_AGENT_ID: identity.agentId ?? '', RALPH_AGENT_NAME: identity.agentName ?? '', ...(typeof localAiTarget === 'string' ? { LOCAL_AI_TARGET: localAiTarget } : {}), ...(typeof opts.exec === 'string' ? { RALPH_EXEC_CMD: opts.exec } : {}) } });
4126
4111
  } catch (err) {
4127
4112
  process.exit(err.status || 1);
4128
4113
  }
@@ -4226,6 +4211,139 @@ program
4226
4211
  }
4227
4212
  });
4228
4213
 
4214
+ const EXPORT_TYPES = ['eventcatalog'];
4215
+
4216
+ program
4217
+ .command('export')
4218
+ .description('Export the event model into another tool\'s format. --type eventcatalog writes an EventCatalog project (domain, flow, events, commands, queries). Reads the board by default; --local reads .slices/ or config.json instead')
4219
+ .requiredOption('--type <type>', `Export target: ${EXPORT_TYPES.join(', ')}`)
4220
+ .option('--context <name>', 'Name or id of the context to export. Omitted (or not found) on the board: pick from the board\'s contexts. Filters --local')
4221
+ .option('--local [path]', 'Read local files instead of the board: ./.slices/, then ./config.json, or the given path')
4222
+ .option('--out <dir>', 'Directory to write into (the catalog root)', '.')
4223
+ .option('--owner <id>', 'Owner (team or user id from the catalog) set on every exported resource; repeat for several', (v, all) => [...all, v], [])
4224
+ .option('--check', 'Compare the model with what the last export wrote and exit 1 if they differ — writes nothing (for CI)')
4225
+ .option('-f, --force', 'Overwrite resources that already exist in the catalog, and re-prompt for credentials when reading the board')
4226
+ .action(async (opts, command) => {
4227
+ if (!EXPORT_TYPES.includes(opts.type)) {
4228
+ console.error(`❌ Unknown export type "${opts.type}". Supported: ${EXPORT_TYPES.join(', ')}`);
4229
+ process.exit(1);
4230
+ }
4231
+ const cwd = process.cwd();
4232
+ const ext = await import('./extensions/eventcatalog/index.js');
4233
+ // Checked first, before any credentials or board access: the export only makes sense inside a catalog.
4234
+ if (!ext.isCatalog(resolve(cwd, opts.out))) {
4235
+ console.error(`❌ ${resolve(cwd, opts.out)} is not an EventCatalog project (no eventcatalog.config.* and no @eventcatalog/core dependency).`);
4236
+ console.error(' Run this inside your catalog, or point --out at it. Create one with: npx @eventcatalog/create-eventcatalog@latest my-catalog --empty');
4237
+ process.exit(1);
4238
+ }
4239
+ let slices;
4240
+ try {
4241
+ if (opts.local) {
4242
+ slices = ext.loadFromLocal({ cwd, local: opts.local, context: opts.context });
4243
+ } else {
4244
+ const kitDir = findInstalledKitDir(cwd);
4245
+ const globalOpts = command.optsWithGlobals();
4246
+ const explicitConfig = globalOpts.config;
4247
+ let cfg = loadEffectiveConfig(cwd, kitDir, explicitConfig).config;
4248
+ const configPath = explicitConfig ? resolve(cwd, explicitConfig) : join(cwd, '.eventmodelers', 'config.json');
4249
+ const requiredFields = ['organizationId', 'boardId', 'token'];
4250
+ const promptForCredentials = async (force = false) => {
4251
+ cfg = await configureCredentials({ config: cfg, configPath, targetDir: cwd, requiredFields, boardIdOptional: false, overrides: {}, print: globalOpts.print, force });
4252
+ if (requiredFields.some((f) => !cfg[f])) {
4253
+ console.error('❌ Still missing token/organizationId/boardId — re-run `eventmodelers export` once configured.');
4254
+ process.exit(1);
4255
+ }
4256
+ };
4257
+ // --force re-asks for credentials, like `init --force` / `init-config --force`.
4258
+ if (opts.force || requiredFields.some((f) => !cfg[f])) await promptForCredentials(opts.force);
4259
+ // A 401/403 re-asks for the token, like `fetch`; any other failure goes to the caller.
4260
+ const withAuthRetry = async (fn) => {
4261
+ try {
4262
+ return await fn();
4263
+ } catch (err) {
4264
+ if (err.status !== 401 && err.status !== 403 && err.status !== 404) throw err;
4265
+ console.error(`❌ ${err.message}`);
4266
+ if (err.status === 404) delete cfg.boardId;
4267
+ else delete cfg.token;
4268
+ await promptForCredentials();
4269
+ return fn();
4270
+ }
4271
+ };
4272
+ const pickContext = async (question) => {
4273
+ const contexts = await withAuthRetry(() => ext.listContexts({ cfg }));
4274
+ if (!contexts.length) throw new Error(`Board ${cfg.boardId} has no contexts with slices.`);
4275
+ return selectPrompt(question, contexts.map((c) => ({ label: `${c.name} (${c.slices} slice${c.slices === 1 ? '' : 's'})`, value: c.name })));
4276
+ };
4277
+ let context = opts.context || (await pickContext('Which context do you want to export?'));
4278
+ try {
4279
+ console.log(`▶ Reading context "${context}" from the board...`);
4280
+ slices = await withAuthRetry(() => ext.loadFromBoard({ cfg, context }));
4281
+ } catch (err) {
4282
+ if (!(err instanceof ext.ContextNotFoundError)) throw err;
4283
+ console.error(`❌ ${err.message}`);
4284
+ context = await pickContext(`No context named "${context}" — pick one of the contexts on this board:`);
4285
+ console.log(`▶ Reading context "${context}" from the board...`);
4286
+ slices = await withAuthRetry(() => ext.loadFromBoard({ cfg, context }));
4287
+ }
4288
+ opts.context = context;
4289
+ }
4290
+ } catch (err) {
4291
+ console.error(`❌ ${err.message}`);
4292
+ process.exit(1);
4293
+ }
4294
+ const outDir = resolve(cwd, opts.out);
4295
+ if (opts.check) {
4296
+ const result = ext.check({ slices, outDir, context: opts.context });
4297
+ if (!result) {
4298
+ console.log('ℹ️ No slices found — nothing to check.');
4299
+ return;
4300
+ }
4301
+ if (result.new.length) console.log(`new (${result.new.length}):\n${result.new.map((l) => ` ${l}`).join('\n')}`);
4302
+ if (result.changed.length) {
4303
+ console.log(`changed (${result.changed.length}):`);
4304
+ for (const { label, changes, renamedFrom } of result.changed) console.log(` ${label}${renamedFrom ? ` (renamed from ${renamedFrom})` : ''}\n${changes.map((c) => ` ${c}`).join('\n')}`);
4305
+ }
4306
+ if (result.removed.length) console.log(`removed from the model (${result.removed.length}):\n${result.removed.map((l) => ` ${l}`).join('\n')}`);
4307
+ if (result.inSync) {
4308
+ console.log(`✅ "${result.context}" is in sync with the catalog (${result.unchanged.length} resources).`);
4309
+ return;
4310
+ }
4311
+ console.log(`❌ "${result.context}" is out of sync. Run \`eventmodelers export --type eventcatalog --context "${result.context}"\` (add --force to update resources already in the catalog).`);
4312
+ process.exit(1);
4313
+ }
4314
+ // The SDK comes from the catalog, never from this CLI. Missing → offer to install it there.
4315
+ let sdk = await ext.loadSdk(outDir);
4316
+ if (!sdk) {
4317
+ console.log(`@eventcatalog/sdk is not installed in ${outDir}.`);
4318
+ const install = await selectPrompt('Install @eventcatalog/sdk into this catalog now (npm install)?', [
4319
+ { label: 'Yes, run `npm install @eventcatalog/sdk` there', value: true },
4320
+ { label: 'No, cancel the export', value: false },
4321
+ ]);
4322
+ if (!install) {
4323
+ console.error('❌ The export needs @eventcatalog/sdk in the catalog. Install it (npm install @eventcatalog/sdk) and re-run.');
4324
+ process.exit(1);
4325
+ }
4326
+ try {
4327
+ ext.installSdk(outDir);
4328
+ sdk = await ext.loadSdk(outDir);
4329
+ } catch (err) {
4330
+ console.error(`❌ Could not install @eventcatalog/sdk: ${err.message}`);
4331
+ process.exit(1);
4332
+ }
4333
+ if (!sdk) {
4334
+ console.error('❌ @eventcatalog/sdk still cannot be resolved from the catalog after installing.');
4335
+ process.exit(1);
4336
+ }
4337
+ }
4338
+ const { written, skipped, model } = await ext.run({ slices, outDir, context: opts.context, force: opts.force, owners: opts.owner, sdk });
4339
+ if (!model) {
4340
+ console.log('ℹ️ No slices found — nothing exported.');
4341
+ return;
4342
+ }
4343
+ console.log(`✅ Exported "${model.domain.name}" to ${outDir}: ${written.length} files written${skipped.length ? `, ${skipped.length} skipped (already in the catalog; use --force to overwrite)` : ''}.`);
4344
+ for (const f of skipped) console.log(` skipped ${f}`);
4345
+ });
4346
+
4229
4347
  program
4230
4348
  .command('scaffold <target> [args...]')
4231
4349
  .description('`scaffold add-user <email> <password> [dir]` adds a sign-in user to a scaffolded app (run in its folder, or pass dir). `scaffold re-init [dir]` refreshes every kit the app has (root and web/) from this CLI version — `re-init --stack` for each, the skeleton itself untouched. Otherwise: generate a runnable app skeleton from the board — sign-in, navigation with one entry per timeline, a page shell per screen, 501 for every unbuilt endpoint, hot reload — and install the build kits of the chosen --type into it, ready for `run` to add slices. <target> is an empty or new directory, or a skeleton made earlier (which is refreshed from the current model).')
@@ -0,0 +1,23 @@
1
+ # EventCatalog export
2
+
3
+ ```
4
+ eventmodelers export --type eventcatalog [--context <name>] [--local [path]] [--out <dir>] [--owner <id>]... [-f, --force]
5
+ ```
6
+
7
+ Reads the slices of an event model and writes EventCatalog resources into `--out` (default: cwd), using the
8
+ **EventCatalog SDK** (`@eventcatalog/sdk`).
9
+
10
+ - Source: the board by default (no `--context` → pick from the board's contexts). `--local` reads `./.slices/`, then `./config.json`, or the path you give.
11
+ - The SDK is resolved from the target catalog's `node_modules` — it is not a dependency of this CLI. If the catalog lacks it, the CLI asks before running `npm install @eventcatalog/sdk` there.
12
+ - `--owner <id>` (repeatable) sets `owners` on every resource; the id must be a team or user in the catalog, or the linter flags it.
13
+ - Resources already in the catalog (same id and version) are skipped. `--force` overwrites them, and — reading the board — asks for credentials again.
14
+
15
+ | Event model | EventCatalog |
16
+ |---|---|
17
+ | context | `domains/<Context>` |
18
+ | chapter | `flows/<Chapter>Flow` (steps ordered by first appearance, edges from dependencies) |
19
+ | events, commands | `events/`, `commands/` with `schema.json` (de-duplicated by title) |
20
+ | read models | `queries/` with `schema.json` |
21
+ | processors, screens | not exported: the event model has no services, so none is imported or created |
22
+
23
+ `model.js` (slices → model) and `render.js` (model → SDK resources) are pure; `write.js` is the only part that touches the catalog.
@@ -0,0 +1,32 @@
1
+ // `eventmodelers export --type eventcatalog` — writes an EventCatalog project's resources
2
+ // (domain, flow, events, commands, queries) from the slices of an event model, through the
3
+ // EventCatalog SDK. Exports run({ slices, outDir, context, force, owners, sdk }).
4
+
5
+ import { buildModel } from './model.js';
6
+ import { toResources } from './render.js';
7
+ import { writeResources } from './write.js';
8
+ import { diffAgainst, readManifest, snapshotsOf, updateManifest } from './manifest.js';
9
+
10
+ export { loadFromBoard, loadFromLocal, listContexts, ContextNotFoundError } from './load.js';
11
+ export { loadSdk, installSdk, isCatalog } from './write.js';
12
+
13
+ // `export --check`: compares the model with what the last export recorded. Writes nothing, needs no SDK.
14
+ export function check({ slices, outDir, context }) {
15
+ if (!slices.length) return null;
16
+ const model = buildModel(slices, { context });
17
+ const diff = diffAgainst(readManifest(outDir), model.domain.name, snapshotsOf(model));
18
+ return { context: model.domain.name, ...diff, inSync: !diff.new.length && !diff.changed.length && !diff.removed.length };
19
+ }
20
+
21
+ // `sdk` is the SDK's factory (see loadSdk); the catalog at `outDir` is where it writes.
22
+ export async function run({ slices, outDir, context, force = false, owners = [], sdk }) {
23
+ if (!slices.length) return { written: [], skipped: [], model: null };
24
+ const model = buildModel(slices, { context });
25
+ const resources = toResources(model, { owners });
26
+ const { written, skipped } = await writeResources(sdk(outDir), resources, { force });
27
+ // Only what was actually written is recorded: a skipped resource keeps its old snapshot (or none), so
28
+ // `--check` keeps reporting it until it is really brought up to date.
29
+ const snapshots = snapshotsOf(model);
30
+ updateManifest(outDir, model.domain.name, Object.fromEntries(written.map((label) => [label, snapshots[label]])));
31
+ return { written, skipped, model };
32
+ }
@@ -0,0 +1,78 @@
1
+ // Where the slices come from: the board (default) or local files (--local).
2
+
3
+ import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
4
+ import { join, resolve } from 'path';
5
+ import { FetchAuthError } from '../../lib/fetch.js';
6
+
7
+ export class ContextNotFoundError extends Error {}
8
+
9
+ const DEFAULT_BASE_URL = 'https://api.eventmodelers.ai';
10
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
11
+
12
+ const readJson = (p) => JSON.parse(readFileSync(p, 'utf-8'));
13
+
14
+ // Every context on the board, as the server resolves them — a timeline with no assigned context counts as its own
15
+ // context. Derived from the slice summaries, so contexts without slices do not show up (there is nothing to export).
16
+ export async function listContexts({ cfg }) {
17
+ const baseUrl = cfg.baseUrl || DEFAULT_BASE_URL;
18
+ const res = await fetch(`${baseUrl}/api/org/${cfg.organizationId}/boards/${cfg.boardId}/slicedata/slices`, { headers: { 'x-token': cfg.token, 'x-user-id': 'cli-export' } });
19
+ if (res.status === 401) throw new FetchAuthError(401, 'slicedata/slices: invalid or expired token');
20
+ if (res.status === 403) throw new FetchAuthError(403, "slicedata/slices: token's organization does not match this board");
21
+ if (res.status === 404) throw new FetchAuthError(404, `slicedata/slices: board ${cfg.boardId} not found`);
22
+ if (!res.ok) throw new Error(`slicedata/slices: HTTP ${res.status}`);
23
+ const counts = new Map();
24
+ for (const s of (await res.json()).slices ?? []) {
25
+ const name = s.contextName || 'default';
26
+ counts.set(name, (counts.get(name) ?? 0) + 1);
27
+ }
28
+ return [...counts].map(([name, slices]) => ({ name, slices }));
29
+ }
30
+
31
+ // Same endpoint `fetch` uses (json format); throws FetchAuthError on 401/403 so the CLI can re-prompt.
32
+ export async function loadFromBoard({ cfg, context }) {
33
+ if (!context) throw new Error('No context given.');
34
+ const baseUrl = cfg.baseUrl || DEFAULT_BASE_URL;
35
+ const query = UUID_RE.test(context) ? `contextId=${encodeURIComponent(context)}` : `contextName=${encodeURIComponent(context)}`;
36
+ const url = `${baseUrl}/api/org/${cfg.organizationId}/boards/${cfg.boardId}/slicedata?${query}&format=json`;
37
+ const res = await fetch(url, { headers: { 'x-token': cfg.token, 'x-user-id': 'cli-export' } });
38
+ if (res.status === 401) throw new FetchAuthError(401, 'slicedata: invalid or expired token');
39
+ if (res.status === 403) throw new FetchAuthError(403, "slicedata: token's organization does not match this board");
40
+ if (res.status === 404) {
41
+ const body = await res.json().catch(() => null);
42
+ throw new ContextNotFoundError(`slicedata: ${body?.error || `no context or timeline named "${context}"`} (board ${cfg.boardId})`);
43
+ }
44
+ if (!res.ok) throw new Error(`slicedata: HTTP ${res.status}`);
45
+ return (await res.json()).slices ?? [];
46
+ }
47
+
48
+ // `local` is true (auto-detect) or a path to a config.json, a .slices/ folder, a context folder, or a slice.json.
49
+ // Auto-detect tries ./.slices/ first, then ./config.json.
50
+ export function loadFromLocal({ cwd, local, context }) {
51
+ const candidates = typeof local === 'string' ? [resolve(cwd, local)] : [join(cwd, '.slices'), join(cwd, 'config.json')];
52
+ const path = candidates.find((p) => existsSync(p));
53
+ if (!path) throw new Error(`No local slices found (looked for ${candidates.join(', ')}).`);
54
+ const slices = statSync(path).isDirectory() ? fromFolder(path) : fromFile(path);
55
+ const wanted = context?.toLowerCase();
56
+ return wanted ? slices.filter((s) => (s.context ?? '').toLowerCase() === wanted) : slices;
57
+ }
58
+
59
+ function fromFile(path) {
60
+ const data = readJson(path);
61
+ return Array.isArray(data.slices) ? data.slices : [data];
62
+ }
63
+
64
+ // A .slices/ folder holds <context>/<slice>/slice.json (and a <context>/config.json dump of the same data).
65
+ function fromFolder(dir) {
66
+ const slices = [];
67
+ const walk = (d, depth) => {
68
+ for (const entry of readdirSync(d, { withFileTypes: true })) {
69
+ const p = join(d, entry.name);
70
+ if (entry.isDirectory() && depth < 3) walk(p, depth + 1);
71
+ else if (entry.name === 'slice.json') slices.push(readJson(p));
72
+ }
73
+ };
74
+ walk(dir, 0);
75
+ // slice.json drops `index`; order from the context's index.json when there is one.
76
+ slices.forEach((s, i) => { if (s.index === undefined) s.index = i; });
77
+ return slices;
78
+ }
@@ -0,0 +1,139 @@
1
+ // What the last export wrote, so `export --check` can tell what moved in the model since — and say what.
2
+ // Only the parts EM-Studio owns are recorded and compared (name, summary from the description, fields,
3
+ // flow steps). Everything else in a catalog resource belongs to the catalog and is never looked at.
4
+ // Kept in the catalog, per context: label ("events/OrderPlaced") → snapshot.
5
+
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
7
+ import { dirname, join } from 'path';
8
+
9
+ const FILE = join('.eventmodelers', 'eventcatalog-export.json');
10
+
11
+ const field = (f) => ({
12
+ name: f.name,
13
+ type: f.type ?? 'String',
14
+ cardinality: f.cardinality ?? 'Single',
15
+ optional: Boolean(f.optional),
16
+ ...(f.example ? { example: f.example } : {}),
17
+ ...(f.subfields?.length ? { subfields: f.subfields.map(field) } : {}),
18
+ });
19
+
20
+ // The summary is only EM-Studio's when the model has a description; otherwise null = "not ours".
21
+ const message = (n) => ({ name: n.name, summary: n.description || null, keys: [...n.elementIds].sort(), fields: n.fields.map(field) });
22
+
23
+ export function snapshotsOf(model) {
24
+ const out = {};
25
+ for (const [folder, list] of [['events', model.events], ['commands', model.commands], ['queries', model.queries]]) {
26
+ for (const n of list) out[`${folder}/${n.id}`] = message(n);
27
+ }
28
+ for (const flow of model.flows) {
29
+ out[`flows/${flow.id}`] = {
30
+ name: flow.name,
31
+ steps: flow.steps.map((s) => ({
32
+ id: s.id, title: s.node.name, keys: [...s.node.elementIds].sort(),
33
+ // next carries the target's keys too, so a renamed target is still the same target
34
+ next: s.next.map((nid) => { const t = flow.steps.find((x) => x.id === nid).node; return { id: nid, keys: [...t.elementIds].sort() }; }),
35
+ })),
36
+ };
37
+ }
38
+ out[`domains/${model.domain.id}`] = { name: model.domain.name };
39
+ return out;
40
+ }
41
+
42
+ export function readManifest(catalogDir) {
43
+ const path = join(catalogDir, FILE);
44
+ if (!existsSync(path)) return {};
45
+ try {
46
+ return JSON.parse(readFileSync(path, 'utf-8'));
47
+ } catch {
48
+ return {}; // unreadable → treated as "nothing exported yet"
49
+ }
50
+ }
51
+
52
+ // Merges `snapshots` into the context's entry.
53
+ export function updateManifest(catalogDir, context, snapshots) {
54
+ const manifest = readManifest(catalogDir);
55
+ manifest[context] = { resources: { ...(manifest[context]?.resources ?? {}), ...snapshots }, exportedAt: new Date().toISOString() };
56
+ const path = join(catalogDir, FILE);
57
+ mkdirSync(dirname(path), { recursive: true });
58
+ writeFileSync(path, JSON.stringify(manifest, null, 2) + '\n');
59
+ }
60
+
61
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
62
+ const quote = (s) => (s === null || s === undefined ? '(none)' : `"${s}"`);
63
+
64
+ function describeFields(before, after, prefix = '') {
65
+ const lines = [];
66
+ const was = new Map(before.map((f) => [f.name, f]));
67
+ const now = new Map(after.map((f) => [f.name, f]));
68
+ for (const [name, f] of now) {
69
+ const old = was.get(name);
70
+ if (!old) { lines.push(`+ field ${prefix}${name} (${f.type}${f.cardinality === 'Single' ? '' : ` ${f.cardinality}`}${f.optional ? ', optional' : ''})`); continue; }
71
+ const parts = [];
72
+ if (old.type !== f.type) parts.push(`type ${old.type} → ${f.type}`);
73
+ if (old.cardinality !== f.cardinality) parts.push(`cardinality ${old.cardinality} → ${f.cardinality}`);
74
+ if (old.optional !== f.optional) parts.push(f.optional ? 'now optional' : 'now required');
75
+ if (old.example !== f.example) parts.push(`example ${quote(old.example)} → ${quote(f.example)}`);
76
+ if (parts.length) lines.push(`~ field ${prefix}${name}: ${parts.join(', ')}`);
77
+ if (old.subfields || f.subfields) lines.push(...describeFields(old.subfields ?? [], f.subfields ?? [], `${prefix}${name}.`));
78
+ }
79
+ for (const name of was.keys()) if (!now.has(name)) lines.push(`- field ${prefix}${name}`);
80
+ return lines;
81
+ }
82
+
83
+ // Two entries are the same element when they share a board id; entries without ids (stubs) match by id.
84
+ const matches = (a, b) => (a.keys?.length && b.keys?.length ? a.keys.some((k) => b.keys.includes(k)) : a.id === b.id);
85
+ const nextIds = (list) => list.map((n) => n.id).join(', ') || '(none)';
86
+
87
+ function describeSteps(before, after) {
88
+ const lines = [];
89
+ const used = new Set();
90
+ for (const s of after) {
91
+ const old = before.find((b) => !used.has(b) && matches(b, s));
92
+ if (!old) { lines.push(`+ step ${s.id}`); continue; }
93
+ used.add(old);
94
+ if (old.id !== s.id) lines.push(`~ step ${old.id} → ${s.id}`);
95
+ const oldNext = old.next.map((n) => (typeof n === 'string' ? { id: n } : n));
96
+ const gone = oldNext.filter((o) => !s.next.some((n) => matches(o, n)));
97
+ const added = s.next.filter((n) => !oldNext.some((o) => matches(o, n)));
98
+ if (gone.length || added.length) lines.push(`~ step ${s.id}: next ${nextIds(oldNext)} → ${nextIds(s.next)}`);
99
+ }
100
+ for (const b of before) if (!used.has(b)) lines.push(`- step ${b.id}`);
101
+ return lines;
102
+ }
103
+
104
+ // The human-readable changes between two snapshots of one resource.
105
+ export function describeChange(before, after) {
106
+ const lines = [];
107
+ if (before.name !== after.name) lines.push(`name: ${quote(before.name)} → ${quote(after.name)}`);
108
+ // A summary only counts when the model has a description (see `message`).
109
+ if (after.summary !== null && after.summary !== undefined && before.summary !== after.summary) lines.push(`summary: ${quote(before.summary)} → ${quote(after.summary)}`);
110
+ if (after.fields) lines.push(...describeFields(before.fields ?? [], after.fields));
111
+ if (after.steps) lines.push(...describeSteps(before.steps ?? [], after.steps));
112
+ return lines;
113
+ }
114
+
115
+ // new: not exported yet · changed: [{ label, changes, renamedFrom? }] · removed: exported before, gone from the model.
116
+ // A "new" and a "removed" resource of the same kind that share a board element id are one resource, renamed.
117
+ export function diffAgainst(manifest, context, snapshots) {
118
+ const before = manifest[context]?.resources ?? {};
119
+ const result = { new: [], changed: [], removed: [], unchanged: [] };
120
+ const kind = (label) => label.split('/')[0];
121
+ const missing = Object.keys(before).filter((label) => !(label in snapshots));
122
+
123
+ for (const [label, snap] of Object.entries(snapshots)) {
124
+ const old = before[label];
125
+ if (old && typeof old === 'object') {
126
+ const changes = describeChange(old, snap);
127
+ if (changes.length) result.changed.push({ label, changes });
128
+ else result.unchanged.push(label);
129
+ continue;
130
+ }
131
+ const was = snap.keys?.length ? missing.find((m) => kind(m) === kind(label) && before[m]?.keys?.some((k) => snap.keys.includes(k))) : null;
132
+ if (!was) { result.new.push(label); continue; }
133
+ missing.splice(missing.indexOf(was), 1);
134
+ const id = (l) => l.split('/').slice(1).join('/');
135
+ result.changed.push({ label, renamedFrom: was, changes: [`id: ${id(was)} → ${id(label)}`, ...describeChange(before[was], snap)] });
136
+ }
137
+ result.removed = missing;
138
+ return result;
139
+ }