@eventmodelers/cli 0.0.6 → 0.0.8

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.
Files changed (48) hide show
  1. package/README.md +42 -9
  2. package/cli.js +633 -175
  3. package/package.json +1 -1
  4. package/{stacks/node/templates → shared}/build-kit/README.md +3 -3
  5. package/{stacks/axon/templates → shared}/build-kit/lib/ralph.js +10 -3
  6. package/shared/build-kit/ralph-ollama.js +2 -2
  7. package/{stacks/node/templates → shared}/build-kit/ralph.sh +1 -1
  8. package/shared/build-kit/realtime-agent.js +1 -1
  9. package/{stacks/supabase/templates/.claude → shared}/skills/connect/SKILL.md +10 -5
  10. package/stacks/cratis-csharp/templates/build-kit/lib/AGENT.md +1 -0
  11. package/stacks/modeling-kit/templates/kit/README.md +79 -0
  12. package/stacks/modeling-kit/templates/kit/lib/ralph.js +9 -2
  13. package/stacks/modeling-kit/templates/kit/ralph.sh +1 -1
  14. package/stacks/node/templates/build-kit/lib/backend-prompt.md +1 -1
  15. package/stacks/supabase/templates/build-kit/lib/backend-prompt.md +1 -1
  16. package/stacks/axon/templates/.claude/skills/connect/SKILL.md +0 -178
  17. package/stacks/axon/templates/.claude/skills/learn-eventmodelers-api/SKILL.md +0 -611
  18. package/stacks/axon/templates/.claude/skills/update-slice-status/SKILL.md +0 -105
  19. package/stacks/axon/templates/build-kit/ralph.sh +0 -98
  20. package/stacks/cratis-csharp/templates/.claude/skills/connect/SKILL.md +0 -178
  21. package/stacks/cratis-csharp/templates/.claude/skills/learn-eventmodelers-api/SKILL.md +0 -609
  22. package/stacks/cratis-csharp/templates/.claude/skills/update-slice-status/SKILL.md +0 -105
  23. package/stacks/cratis-csharp/templates/build-kit/lib/agent.sh +0 -20
  24. package/stacks/cratis-csharp/templates/build-kit/lib/ralph.js +0 -302
  25. package/stacks/cratis-csharp/templates/build-kit/ralph-claude.js +0 -37
  26. package/stacks/cratis-csharp/templates/build-kit/ralph.sh +0 -98
  27. package/stacks/modeling-kit/templates/.claude/skills/connect/SKILL.md +0 -178
  28. package/stacks/modeling-kit/templates/.claude/skills/learn-eventmodelers-api/SKILL.md +0 -441
  29. package/stacks/modeling-kit/templates/.claude/skills/update-slice-status/SKILL.md +0 -110
  30. package/stacks/modeling-kit/templates/kit/lib/agent.sh +0 -20
  31. package/stacks/modeling-kit/templates/kit/lib/ollama-agent.js +0 -147
  32. package/stacks/modeling-kit/templates/kit/ralph-ollama.js +0 -38
  33. package/stacks/modeling-kit/templates/kit/realtime-agent.js +0 -18
  34. package/stacks/node/templates/.claude/skills/connect/SKILL.md +0 -178
  35. package/stacks/node/templates/build-kit/lib/agent.sh +0 -20
  36. package/stacks/node/templates/build-kit/lib/ralph.js +0 -369
  37. package/stacks/node/templates/build-kit/ralph-claude.js +0 -44
  38. package/stacks/supabase/templates/.claude/skills/learn-eventmodelers-api/SKILL.md +0 -628
  39. package/stacks/supabase/templates/.claude/skills/update-slice-status/SKILL.md +0 -110
  40. package/stacks/supabase/templates/build-kit/README.md +0 -86
  41. package/stacks/supabase/templates/build-kit/lib/agent.sh +0 -20
  42. package/stacks/supabase/templates/build-kit/lib/ralph.js +0 -369
  43. package/stacks/supabase/templates/build-kit/ralph-claude.js +0 -44
  44. package/stacks/supabase/templates/build-kit/ralph.sh +0 -98
  45. /package/{stacks/axon/templates → shared}/build-kit/lib/agent.sh +0 -0
  46. /package/{stacks/axon/templates → shared}/build-kit/ralph-claude.js +0 -0
  47. /package/{stacks/node/templates/.claude → shared}/skills/learn-eventmodelers-api/SKILL.md +0 -0
  48. /package/{stacks/node/templates/.claude → shared}/skills/update-slice-status/SKILL.md +0 -0
package/cli.js CHANGED
@@ -1,29 +1,37 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { Command } from 'commander';
4
- import { fileURLToPath } from 'url';
4
+ import { fileURLToPath, pathToFileURL } from 'url';
5
5
  import { dirname, join, relative, resolve, sep } from 'path';
6
6
  import {
7
7
  existsSync,
8
8
  mkdirSync,
9
9
  cpSync,
10
+ copyFileSync,
10
11
  rmSync,
11
12
  readdirSync,
12
13
  writeFileSync,
13
14
  readFileSync,
14
15
  appendFileSync,
15
16
  } from 'fs';
16
- import { execSync } from 'child_process';
17
+ import { execSync, spawn } from 'child_process';
17
18
  import { createInterface, emitKeypressEvents, moveCursor, clearScreenDown } from 'readline';
18
19
  import { homedir } from 'os';
19
20
 
20
21
  const __filename = fileURLToPath(import.meta.url);
21
22
  const __dirname = dirname(__filename);
22
23
 
23
- // Each backend stack is a template set under stacks/<key>/templates/{.claude,root,build-kit}.
24
- // They also get shared/build-kit/* copied into their kit dir first — those files
25
- // (ralph-ollama.js, realtime-agent.js, code-export.mjs, lib/ollama-agent.js, package.json)
26
- // are identical across all of them.
24
+ // Each stack is a template set under stacks/<key>/templates/{.claude,root,<kitSubdir>}.
25
+ // Stacks with useShared:true also get shared/build-kit/* copied into their kit dir
26
+ // first (ralph.js, ralph-claude.js, ralph-ollama.js, ralph.sh, realtime-agent.js,
27
+ // code-export.mjs, lib/agent.sh, lib/ollama-agent.js, package.json, README.md)
28
+ // those files have no per-stack content, so they live once instead of being
29
+ // copy-pasted into every stack (that copy-pasting is exactly how they drifted out
30
+ // of sync before: a bugfix or default landing in one stack's copy but not another's).
31
+ // Each stack's own templates/<kitSubdir>/* is then overlaid on top for genuine
32
+ // per-stack differences (ralph-claude.js's build tooling, lib/prompt.md, etc.) —
33
+ // see stacks/modeling-kit for an example of a kit with fully different entry-point
34
+ // logic that still reuses the shared runtime pieces.
27
35
  const STACKS = {
28
36
  node: {
29
37
  label: 'Node.js / TypeScript',
@@ -62,7 +70,7 @@ const MODELING_KIT = {
62
70
  label: 'Modeling only — skills + agent loop, no backend scaffold',
63
71
  kitSubdir: 'kit',
64
72
  kitDirName: '.agent-modeling-kit',
65
- useShared: false,
73
+ useShared: true,
66
74
  needsBoardId: false,
67
75
  };
68
76
 
@@ -88,6 +96,112 @@ const MCP_MANUAL_CLIENTS = [
88
96
  { label: 'Windsurf', hint: (url) => `Add an HTTP MCP server pointing at ${url} in Windsurf's MCP settings` },
89
97
  ];
90
98
 
99
+ // Other AI agent hosts can read our Event Modeling skills without us duplicating
100
+ // skill content per host: a thin stub file in the host's own command/workflow
101
+ // directory tells it to go read the canonical .claude/skills/<name>/SKILL.md and
102
+ // follow it — the same pattern spec-kitty uses (verified directly against its repo,
103
+ // not just its docs: every "stub" host below reads plain Markdown, no per-host
104
+ // format transform needed). Codex CLI, Mistral Vibe, Pi, and Letta Code share one
105
+ // convention that already matches our native SKILL.md format, so those get the
106
+ // real file copied as-is instead of a stub.
107
+ const AGENT_HOSTS = {
108
+ cursor: { label: 'Cursor', dir: '.cursor/commands', kind: 'stub' },
109
+ windsurf: { label: 'Windsurf', dir: '.windsurf/workflows', kind: 'stub' },
110
+ gemini: { label: 'Google Gemini CLI', dir: '.gemini/commands', kind: 'stub' },
111
+ qwen: { label: 'Qwen Code', dir: '.qwen/commands', kind: 'stub' },
112
+ opencode: { label: 'OpenCode', dir: '.opencode/command', kind: 'stub' },
113
+ copilot: { label: 'GitHub Copilot', dir: '.github/prompts', kind: 'stub' },
114
+ amazonq: { label: 'Amazon Q (legacy)', dir: '.amazonq/prompts', kind: 'stub' },
115
+ kiro: { label: 'Kiro', dir: '.kiro/prompts', kind: 'stub' },
116
+ kilocode: { label: 'Kilocode', dir: '.kilocode/workflows', kind: 'stub' },
117
+ augment: { label: 'Augment Code', dir: '.augment/commands', kind: 'stub' },
118
+ antigravity: { label: 'Google Antigravity', dir: '.agent/workflows', kind: 'stub' },
119
+ codex: {
120
+ label: 'Codex CLI / Mistral Vibe / Pi / Letta Code (shared .agents/skills/ convention)',
121
+ dir: '.agents/skills',
122
+ kind: 'skill-package',
123
+ },
124
+ };
125
+
126
+ function agentHostStub(skillName) {
127
+ return `# ${skillName} (eventmodelers)\n\nThis host should read the canonical skill at:\n\n**\`.claude/skills/${skillName}/SKILL.md\`**\n\nFollow those instructions when this command is invoked.\n`;
128
+ }
129
+
130
+ // Writes stub/skill-package files for the requested hosts and records exactly what
131
+ // it wrote into every installed kit dir's manifest — same convention as
132
+ // `mcpRegistered` — so `uninstall` can remove precisely these files later.
133
+ async function configureAgentHosts({ hosts, global: useGlobal } = {}) {
134
+ const targetDir = process.cwd();
135
+ const skillsDir = useGlobal ? join(homedir(), '.claude', 'skills') : join(targetDir, '.claude', 'skills');
136
+
137
+ if (!existsSync(skillsDir)) {
138
+ console.error(`❌ No skills found at ${relative(targetDir, skillsDir) || skillsDir} — run \`eventmodelers init\` or \`init-modeling\` first.`);
139
+ process.exit(1);
140
+ }
141
+ const skills = readdirSync(skillsDir).filter((f) => existsSync(join(skillsDir, f, 'SKILL.md')));
142
+ if (!skills.length) {
143
+ console.error('❌ No installed skills found — nothing to expose.');
144
+ process.exit(1);
145
+ }
146
+
147
+ let hostKeys = hosts;
148
+ if (!hostKeys || !hostKeys.length) {
149
+ console.log('\nAvailable agent hosts:');
150
+ Object.entries(AGENT_HOSTS).forEach(([key, h]) => console.log(` ${key.padEnd(12)} ${h.label}`));
151
+ const answer = await prompt('\nWhich hosts? (comma-separated keys, or "all"): ');
152
+ hostKeys = answer.trim() === 'all'
153
+ ? Object.keys(AGENT_HOSTS)
154
+ : answer.split(',').map((s) => s.trim()).filter(Boolean);
155
+ }
156
+
157
+ const unknown = hostKeys.filter((k) => !AGENT_HOSTS[k]);
158
+ if (unknown.length) {
159
+ console.error(`❌ Unknown host(s): ${unknown.join(', ')}. Available: ${Object.keys(AGENT_HOSTS).join(', ')}`);
160
+ process.exit(1);
161
+ }
162
+ if (!hostKeys.length) {
163
+ console.log('ℹ️ No hosts selected — nothing to do.');
164
+ return;
165
+ }
166
+
167
+ console.log(`\n📦 Exposing ${skills.length} skill(s) to ${hostKeys.length} host(s)...`);
168
+ const generatedFiles = [];
169
+
170
+ for (const key of hostKeys) {
171
+ const host = AGENT_HOSTS[key];
172
+ if (host.kind === 'stub') {
173
+ const hostDir = join(targetDir, host.dir);
174
+ mkdirSync(hostDir, { recursive: true });
175
+ for (const skill of skills) {
176
+ const filePath = join(hostDir, `${skill}.md`);
177
+ writeFileSync(filePath, agentHostStub(skill));
178
+ generatedFiles.push(relative(targetDir, filePath));
179
+ }
180
+ } else {
181
+ // skill-package: copy the real SKILL.md verbatim — already the native format.
182
+ for (const skill of skills) {
183
+ const pkgDir = join(targetDir, host.dir, `eventmodelers.${skill}`);
184
+ mkdirSync(pkgDir, { recursive: true });
185
+ const dest = join(pkgDir, 'SKILL.md');
186
+ copyFileSync(join(skillsDir, skill, 'SKILL.md'), dest);
187
+ generatedFiles.push(relative(targetDir, dest));
188
+ }
189
+ }
190
+ console.log(` ✓ ${host.label} (${host.dir}/)`);
191
+ }
192
+
193
+ for (const dirName of KIT_DIR_NAMES) {
194
+ const manifestPath = join(targetDir, dirName, '.eventmodelers', 'install-manifest.json');
195
+ if (existsSync(manifestPath)) {
196
+ const manifest = readJsonSafe(manifestPath);
197
+ manifest.agentHostFiles = [...new Set([...(manifest.agentHostFiles || []), ...generatedFiles])];
198
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
199
+ }
200
+ }
201
+
202
+ console.log(`\n✅ Done — ${generatedFiles.length} file(s) written.`);
203
+ }
204
+
91
205
  // Every config field can also be set via an EVENTMODELERS_* env var — these always
92
206
  // win over whatever's in config.json, so scripted/CI installs can skip prompts entirely.
93
207
  const ENV_CONFIG_MAP = {
@@ -156,9 +270,33 @@ async function promptPasteBlock() {
156
270
  return lines.join('\n').trim();
157
271
  }
158
272
 
273
+ // Platform base URL when a config doesn't specify one — every install method
274
+ // (paste, manual entry, or a hand-edited config.json) should fall back to this
275
+ // rather than silently disabling platform sync.
276
+ const DEFAULT_BASE_URL = 'https://api.eventmodelers.ai';
277
+
278
+ // Canonical order the account page pastes values in, regardless of which fields a
279
+ // given stack actually requires — a modeling-kit install (no boardId required) still
280
+ // gets a paste containing all 4 fields, so we must not drop the ones we don't need.
281
+ const PASTE_FIELD_ORDER = ['organizationId', 'boardId', 'token'];
282
+
283
+ // Values are sometimes copied with a "field=" prefix still attached (e.g. lifted
284
+ // straight out of a query string) — and occasionally under the wrong JSON key
285
+ // entirely, e.g. { "organizationId": "token=abc..." }. When a value carries its own
286
+ // "field=" prefix, that's a more reliable source of truth than whatever key/position
287
+ // it was pasted under, so it wins.
288
+ const PASTE_FIELD_ALIASES = { organizationId: 'organizationId', orgId: 'organizationId', boardId: 'boardId', token: 'token', baseUrl: 'baseUrl' };
289
+
290
+ function splitEmbeddedField(value) {
291
+ if (typeof value !== 'string') return null;
292
+ const match = value.match(/^([a-zA-Z]+)=(.+)$/);
293
+ if (!match) return null;
294
+ const field = PASTE_FIELD_ALIASES[match[1]];
295
+ return field ? { field, value: match[2] } : null;
296
+ }
297
+
159
298
  // Accepts either a JSON object (as copied from the account page) or a comma-separated
160
- // line of values, in the same order as requiredFields (organizationId[, boardId], token),
161
- // with an optional base URL anywhere in the list.
299
+ // line of values, in PASTE_FIELD_ORDER, with an optional base URL anywhere in the list.
162
300
  function parseCredentialsPaste(text, requiredFields) {
163
301
  const trimmed = text.trim();
164
302
  if (!trimmed) return null;
@@ -166,11 +304,18 @@ function parseCredentialsPaste(text, requiredFields) {
166
304
  try {
167
305
  const obj = JSON.parse(trimmed);
168
306
  if (obj && typeof obj === 'object') {
307
+ const raw = {};
308
+ if (obj.organizationId || obj.orgId) raw.organizationId = obj.organizationId || obj.orgId;
309
+ if (obj.boardId) raw.boardId = obj.boardId;
310
+ if (obj.token) raw.token = obj.token;
311
+ if (obj.baseUrl) raw.baseUrl = obj.baseUrl;
312
+
169
313
  const result = {};
170
- if (obj.organizationId || obj.orgId) result.organizationId = obj.organizationId || obj.orgId;
171
- if (obj.boardId) result.boardId = obj.boardId;
172
- if (obj.token) result.token = obj.token;
173
- if (obj.baseUrl) result.baseUrl = obj.baseUrl;
314
+ for (const [outerKey, value] of Object.entries(raw)) {
315
+ const embedded = splitEmbeddedField(value);
316
+ if (embedded) result[embedded.field] = embedded.value;
317
+ else result[outerKey] = value;
318
+ }
174
319
  if (requiredFields.every((f) => result[f])) return result;
175
320
  return null;
176
321
  }
@@ -182,13 +327,26 @@ function parseCredentialsPaste(text, requiredFields) {
182
327
  const result = {};
183
328
  const remaining = [];
184
329
  for (const v of values) {
185
- if (/^https?:\/\//i.test(v)) result.baseUrl = v;
330
+ if (/^https?:\/\//i.test(v)) {
331
+ result.baseUrl = v;
332
+ continue;
333
+ }
334
+ const embedded = splitEmbeddedField(v);
335
+ if (embedded) result[embedded.field] = embedded.value;
186
336
  else remaining.push(v);
187
337
  }
188
- if (remaining.length < requiredFields.length) return null;
189
- requiredFields.forEach((field, i) => {
190
- result[field] = remaining[i];
191
- });
338
+ if (remaining.length < requiredFields.length - Object.keys(result).length) return null;
339
+ // If more values were pasted than this stack strictly requires (e.g. a boardId
340
+ // in a modeling-kit paste), use the full canonical order so the extra field is
341
+ // still captured instead of being mis-zipped against the shorter requiredFields
342
+ // list and silently dropped/misassigned.
343
+ const fieldOrder = remaining.length > requiredFields.length ? PASTE_FIELD_ORDER : requiredFields;
344
+ let i = 0;
345
+ for (const field of fieldOrder) {
346
+ if (result[field]) continue; // already resolved via an embedded "field=" prefix
347
+ if (remaining[i] !== undefined) result[field] = remaining[i];
348
+ i++;
349
+ }
192
350
 
193
351
  return requiredFields.every((f) => result[f]) ? result : null;
194
352
  }
@@ -254,9 +412,15 @@ function findConfigInParents(startDir) {
254
412
  const candidate = join(dir, '.eventmodelers', 'config.json');
255
413
  if (existsSync(candidate)) return candidate;
256
414
  const parent = dirname(dir);
257
- if (parent === dir) return null;
415
+ if (parent === dir) break;
258
416
  dir = parent;
259
417
  }
418
+ // Last resort: the walk above only passes through $HOME if the project happens
419
+ // to live under it. A project outside $HOME (e.g. /tmp/foo) never sees it, so
420
+ // check it explicitly — this is where `init-config --global` writes account-wide
421
+ // defaults (organizationId/token) shared across every project.
422
+ const globalCandidate = join(homedir(), '.eventmodelers', 'config.json');
423
+ return existsSync(globalCandidate) ? globalCandidate : null;
260
424
  }
261
425
 
262
426
  function readJsonSafe(path) {
@@ -311,6 +475,7 @@ function findAllInstalledKitDirs(cwd) {
311
475
  function copyDirContents(srcDir, destDir, { skip = [] } = {}) {
312
476
  if (!existsSync(srcDir)) return;
313
477
  mkdirSync(destDir, { recursive: true });
478
+ let count = 0;
314
479
  for (const item of readdirSync(srcDir)) {
315
480
  if (skip.includes(item)) continue;
316
481
  const src = join(srcDir, item);
@@ -319,8 +484,9 @@ function copyDirContents(srcDir, destDir, { skip = [] } = {}) {
319
484
  recursive: true,
320
485
  filter: (s) => !relative(src, s).split(sep).includes('node_modules'),
321
486
  });
322
- console.log(` ✓ Installed ${relative(process.cwd(), dest)}`);
487
+ count++;
323
488
  }
489
+ if (count) console.log(` ✓ Installed ${count} item${count === 1 ? '' : 's'} into ${relative(process.cwd(), destDir) || '.'}`);
324
490
  }
325
491
 
326
492
  async function resolveStack(cliStack) {
@@ -345,6 +511,12 @@ async function installStack(stackKey, stackCfg, options = {}) {
345
511
  const targetDir = process.cwd();
346
512
  const templatesSource = join(__dirname, 'stacks', stackKey, 'templates');
347
513
  const sharedBuildKit = join(__dirname, 'shared', 'build-kit');
514
+ // Skills with no stack-specific content (connect, learn-eventmodelers-api,
515
+ // update-slice-status, ...) live once here instead of being copy-pasted into
516
+ // every stack's templates — that copy-pasting is exactly how they drifted out
517
+ // of sync with each other before (e.g. one stack's connect skill silently
518
+ // missing a bugfix another stack's copy had).
519
+ const sharedSkills = join(__dirname, 'shared', 'skills');
348
520
 
349
521
  if (!existsSync(templatesSource)) {
350
522
  console.error('❌ Templates directory not found at:', templatesSource);
@@ -355,18 +527,21 @@ async function installStack(stackKey, stackCfg, options = {}) {
355
527
  // Recorded into the install manifest (step 8) so `uninstall` can remove exactly
356
528
  // these files later and nothing the user added independently.
357
529
  const claudeSkillsSrc = join(templatesSource, '.claude', 'skills');
358
- const installedSkills = existsSync(claudeSkillsSrc) ? readdirSync(claudeSkillsSrc) : [];
530
+ const installedSkills = [
531
+ ...(existsSync(sharedSkills) ? readdirSync(sharedSkills) : []),
532
+ ...(existsSync(claudeSkillsSrc) ? readdirSync(claudeSkillsSrc) : []),
533
+ ];
359
534
  let claudeExtras = [];
360
535
 
361
536
  if (options.global) {
362
537
  const globalSkillsDir = join(homedir(), '.claude', 'skills');
363
- console.log('📦 Installing Claude skills globally...');
364
- console.log(` Copies skills into ${globalSkillsDir} so they're available in every project, not just this one.\n`);
538
+ console.log('📦 Installing skills globally...');
539
+ copyDirContents(sharedSkills, globalSkillsDir);
365
540
  copyDirContents(claudeSkillsSrc, globalSkillsDir);
366
541
  } else {
367
- console.log('📦 Installing Claude skills...');
368
- console.log(' Copies skills and settings into .claude/ so Claude Code picks them up automatically.\n');
542
+ console.log('📦 Installing skills...');
369
543
  copyDirContents(join(templatesSource, '.claude'), join(targetDir, '.claude'));
544
+ copyDirContents(sharedSkills, join(targetDir, '.claude', 'skills'));
370
545
  claudeExtras = existsSync(join(templatesSource, '.claude'))
371
546
  ? readdirSync(join(templatesSource, '.claude')).filter((f) => f !== 'skills')
372
547
  : [];
@@ -375,15 +550,14 @@ async function installStack(stackKey, stackCfg, options = {}) {
375
550
  // --- 2. Spread stack scaffold files into the project root ---
376
551
  const rootSrc = join(templatesSource, 'root');
377
552
  if (existsSync(rootSrc)) {
378
- console.log('\n📦 Installing project files...\n');
553
+ console.log('📦 Installing project files...');
379
554
  copyDirContents(rootSrc, targetDir);
380
555
  }
381
556
 
382
557
  // --- 3. Create the kit dir and install the agent runner ---
383
558
  const kitDir = join(targetDir, stackCfg.kitDirName);
384
559
  mkdirSync(kitDir, { recursive: true });
385
- console.log(`\n📦 Installing agent kit into ${stackCfg.kitDirName}/...`);
386
- console.log(' Sets up the Ralph agent loop, scripts, and configuration that drive realtime modeling.\n');
560
+ console.log(`📦 Installing agent kit into ${stackCfg.kitDirName}/...`);
387
561
 
388
562
  if (stackCfg.useShared) {
389
563
  copyDirContents(sharedBuildKit, kitDir);
@@ -400,8 +574,7 @@ async function installStack(stackKey, stackCfg, options = {}) {
400
574
 
401
575
  // --- 4. Install kit dependencies ---
402
576
  if (existsSync(join(kitDir, 'package.json'))) {
403
- console.log('\n📦 Installing kit dependencies...');
404
- console.log(' Installs npm packages required by the agent scripts (e.g. websocket client, utilities).');
577
+ console.log('📦 Installing kit dependencies...');
405
578
  try {
406
579
  execSync('npm install', { cwd: kitDir, stdio: ['ignore', 'inherit', 'inherit'] });
407
580
  console.log(' ✓ kit dependencies installed');
@@ -411,9 +584,7 @@ async function installStack(stackKey, stackCfg, options = {}) {
411
584
  }
412
585
 
413
586
  // --- 5. Credentials ---
414
- console.log('\n🔐 Configuring credentials...');
415
- console.log(' Stores your Organization ID (and Board ID, if this stack needs one) and token');
416
- console.log(' so the agent can connect to app.eventmodelers.ai.\n');
587
+ console.log('🔐 Configuring credentials...');
417
588
 
418
589
  // Written at the project root (not inside the kit dir) so a modeling-kit install
419
590
  // and a build-kit install in the same project share one config.json instead of
@@ -421,9 +592,60 @@ async function installStack(stackKey, stackCfg, options = {}) {
421
592
  const configPath = options.configPath
422
593
  ? resolve(targetDir, options.configPath)
423
594
  : join(targetDir, '.eventmodelers', 'config.json');
424
- const configDir = dirname(configPath);
425
- mkdirSync(configDir, { recursive: true });
426
595
 
596
+ const requiredFields = stackCfg.needsBoardId
597
+ ? ['organizationId', 'boardId', 'token']
598
+ : ['organizationId', 'token'];
599
+
600
+ const effective = loadEffectiveConfig(targetDir, kitDir, options.configPath);
601
+ if (effective.sources.length > 1) {
602
+ console.log(`\n ✓ Found shared defaults in ${effective.sources[0]}`);
603
+ }
604
+
605
+ const config = await configureCredentials({
606
+ config: effective.config,
607
+ configPath,
608
+ targetDir,
609
+ requiredFields,
610
+ boardIdOptional: !stackCfg.needsBoardId,
611
+ overrides: options.credentialOverrides,
612
+ print: options.print,
613
+ });
614
+
615
+ // --- 6. Install manifest (drives precise `uninstall` later) ---
616
+ // Only the footprint listed here is ever removed by `uninstall` — the root
617
+ // scaffold (step 2) is real project source the user builds on, so it's
618
+ // deliberately left out and never touched by uninstall.
619
+ const manifestDir = join(kitDir, '.eventmodelers');
620
+ mkdirSync(manifestDir, { recursive: true });
621
+ writeFileSync(
622
+ join(manifestDir, 'install-manifest.json'),
623
+ JSON.stringify({ stack: stackKey, global: !!options.global, skills: installedSkills, claudeExtras, mcpRegistered: false }, null, 2),
624
+ );
625
+
626
+ console.log('\n✅ Done! Start your agent:\n');
627
+ console.log(' npx @eventmodelers/cli run (--ollama or --bash for other runners)\n');
628
+ console.log('Connect this project to an MCP client (Claude Code, VS Code, ...):\n');
629
+ console.log(` npx @eventmodelers/cli init-mcp\n`);
630
+ console.log('Expose these skills to other AI agent hosts (Cursor, Windsurf, Gemini CLI, Copilot, Codex CLI, Kiro, ...):\n');
631
+ console.log(` npx @eventmodelers/cli init-agents\n`);
632
+ }
633
+
634
+ // Extracted from installStack so `init-config` can reuse the exact same
635
+ // paste/manual/instructions/skip flow without also scaffolding a stack.
636
+ // `overrides` are values passed directly on the command line (--token, --board-id,
637
+ // --organization-id, --base-url) — the most explicit source available, so they
638
+ // win over both the config file and env vars before we even check what's missing.
639
+ async function configureCredentials({ config, configPath, targetDir, requiredFields, boardIdOptional, overrides = {}, print, skipGitignore = false }) {
640
+ config = { ...config };
641
+ for (const [field, value] of Object.entries(overrides)) {
642
+ if (value) config[field] = value;
643
+ }
644
+
645
+ const configDir = dirname(configPath);
646
+ mkdirSync(configDir, { recursive: true });
647
+
648
+ if (!skipGitignore) {
427
649
  const gitignorePath = join(targetDir, '.gitignore');
428
650
  const relConfigDir = relative(targetDir, configDir);
429
651
  if (relConfigDir && !relConfigDir.startsWith('..')) {
@@ -437,157 +659,245 @@ async function installStack(stackKey, stackCfg, options = {}) {
437
659
  writeFileSync(gitignorePath, `${gitignoreEntry}\n`);
438
660
  }
439
661
  }
662
+ }
440
663
 
441
- const requiredFields = stackCfg.needsBoardId
442
- ? ['organizationId', 'boardId', 'token']
443
- : ['organizationId', 'token'];
444
-
445
- const effective = loadEffectiveConfig(targetDir, kitDir, options.configPath);
446
- let config = effective.config;
447
- if (effective.sources.length > 1) {
448
- console.log(`\n ✓ Found shared defaults in ${effective.sources[0]}`);
449
- }
450
- const hasConfig = requiredFields.every((f) => config[f]);
451
-
452
- const stillMissing = requiredFields.some((f) => !config[f]);
453
- if (stillMissing && options.print) {
454
- console.log('\n ℹ️ --print skipping credential prompt, missing fields must be set via EVENTMODELERS_* env vars or config.json');
455
- } else if (stillMissing) {
456
- const choice = await selectPrompt('How do you want to configure credentials?', [
457
- { label: 'Paste values copied from app.eventmodelers.ai/account', value: 'paste' },
458
- { label: 'Enter values one by one', value: 'manual' },
459
- { label: 'Get instructions for configuring later', value: 'instructions' },
460
- { label: 'Skip for now', value: 'skip' },
461
- ], 1);
462
-
463
- if (choice === 'paste') {
464
- console.log('\n Copy your credentials from https://app.eventmodelers.ai/account,');
465
- console.log(' then paste them below and press Enter:\n');
466
- const pasted = await promptPasteBlock();
467
- const parsed = parseCredentialsPaste(pasted, requiredFields);
468
- if (parsed) {
469
- config = { ...config, ...parsed };
470
- writeFileSync(configPath, JSON.stringify(config, null, 2));
471
- console.log(`\n ✓ Saved to ${relative(targetDir, configPath)}`);
472
- } else {
473
- console.log(`\n ⚠️ Couldn't make sense of that paste — nothing was saved.`);
474
- console.log(` Paste it into ${relative(targetDir, configPath)} yourself, or use /connect later.`);
475
- }
476
- } else if (choice === 'manual') {
477
- console.log('\n🔑 Enter your Eventmodelers credentials:\n');
478
- config.organizationId = await prompt(' Organization ID: ');
479
- if (stackCfg.needsBoardId) {
480
- config.boardId = await prompt(' Board ID: ');
481
- }
482
- config.token = await prompt(' Token: ');
664
+ const stillMissing = requiredFields.some((f) => !config[f]);
665
+ if (stillMissing && print) {
666
+ console.log('\n ℹ️ --print — skipping credential prompt, missing fields must be set via flags, EVENTMODELERS_* env vars, or config.json');
667
+ } else if (stillMissing) {
668
+ const choice = await selectPrompt('How do you want to configure credentials?', [
669
+ { label: 'Paste values copied from app.eventmodelers.ai/account', value: 'paste' },
670
+ { label: 'Enter values one by one', value: 'manual' },
671
+ { label: 'Get instructions for configuring later', value: 'instructions' },
672
+ { label: 'Skip for now', value: 'skip' },
673
+ ], 1);
674
+
675
+ if (choice === 'paste') {
676
+ console.log('\n Copy your credentials from https://app.eventmodelers.ai/account,');
677
+ console.log(' then paste them below and press Enter:\n');
678
+ const pasted = await promptPasteBlock();
679
+ const parsed = parseCredentialsPaste(pasted, requiredFields);
680
+ if (parsed) {
681
+ config = { ...config, ...parsed };
682
+ if (!config.baseUrl) config.baseUrl = DEFAULT_BASE_URL;
483
683
  writeFileSync(configPath, JSON.stringify(config, null, 2));
484
- console.log(`\n ✓ Credentials saved to ${relative(targetDir, configPath)}`);
485
- } else if (choice === 'instructions') {
486
- console.log(`\n Paste your credentials into:\n`);
487
- console.log(` ${configPath}`);
488
- console.log(`\n (or any ancestor directory's .eventmodelers/config.json, e.g. ~/.eventmodelers/config.json`);
489
- console.log(` to share the same credentials across multiple projects)\n`);
490
- console.log(` The file should look like:`);
491
- const sample = stackCfg.needsBoardId
492
- ? ` {\n "token": "...",\n "boardId": "...",\n "organizationId": "...",\n "baseUrl": "https://api.eventmodelers.ai"\n }\n`
493
- : ` {\n "token": "...",\n "organizationId": "...",\n "baseUrl": "https://api.eventmodelers.ai"\n }\n`;
494
- console.log(sample);
495
- console.log(' Then re-run this installer, or just run the agent afterwards.\n');
684
+ console.log(`\n ✓ Saved to ${relative(targetDir, configPath)}`);
496
685
  } else {
497
- console.log('\n ℹ️ Skipped use /connect in Claude Code to add credentials later');
686
+ console.log(`\n ⚠️ Couldn't make sense of that paste nothing was saved.`);
687
+ console.log(` Paste it into ${relative(targetDir, configPath)} yourself, or use /connect later.`);
498
688
  }
689
+ } else if (choice === 'manual') {
690
+ console.log('\n🔑 Enter your Eventmodelers credentials:\n');
691
+ config.organizationId = await prompt(' Organization ID: ');
692
+ // Always ask, even when this install doesn't strictly require it — it's still
693
+ // used as a fallback default by the agent loop (see BOARD_ID resolution).
694
+ const boardId = await prompt(` Board ID${boardIdOptional ? ' (optional)' : ''}: `);
695
+ if (boardId) config.boardId = boardId;
696
+ config.token = await prompt(' Token: ');
697
+ if (!config.baseUrl) config.baseUrl = DEFAULT_BASE_URL;
698
+ writeFileSync(configPath, JSON.stringify(config, null, 2));
699
+ console.log(`\n ✓ Credentials saved to ${relative(targetDir, configPath)}`);
700
+ } else if (choice === 'instructions') {
701
+ console.log(`\n Paste your credentials into:\n`);
702
+ console.log(` ${configPath}`);
703
+ console.log(`\n (or any ancestor directory's .eventmodelers/config.json, e.g. ~/.eventmodelers/config.json`);
704
+ console.log(` to share the same credentials across multiple projects)\n`);
705
+ console.log(` The file should look like:`);
706
+ const sample = ` {\n "token": "...",\n "boardId": "...",\n "organizationId": "...",\n "baseUrl": "https://api.eventmodelers.ai"\n }\n`;
707
+ console.log(sample);
708
+ console.log(' Then re-run this installer, or just run the agent afterwards.\n');
499
709
  } else {
500
- console.log('\n Config already present skipping credential prompt');
710
+ console.log('\n ℹ️ Skipped use /connect in Claude Code to add credentials later');
501
711
  }
712
+ } else {
713
+ console.log('\n ✓ Config already present — skipping credential prompt');
714
+ }
502
715
 
503
- // Claude vs. Ollama, and any custom Anthropic-compatible base URL, is a `run`-time
504
- // choice (`eventmodelers run` vs `--ollama`) not an install-time one. Power users
505
- // can still pin config.anthropicBaseUrl/model via EVENTMODELERS_ANTHROPIC_BASE_URL /
506
- // EVENTMODELERS_MODEL env vars or by editing config.json directly; loadEffectiveConfig
507
- // already picks those up above, so nothing further to do here.
508
- writeFileSync(configPath, JSON.stringify(config, null, 2));
509
- console.log(`\n ✓ Saved to ${relative(targetDir, configPath)}`);
716
+ // Backfill baseUrl for configs that already had real credentials but predate
717
+ // this default (e.g. a config.json written by hand or by an older CLI version).
718
+ if (config.token && config.organizationId && !config.baseUrl) {
719
+ config.baseUrl = DEFAULT_BASE_URL;
720
+ }
510
721
 
511
- // --- 6. MCP server in .claude/settings.json ---
512
- console.log('\n🔌 Configuring MCP server...');
513
- console.log(' Registers the Eventmodelers MCP server in .claude/settings.json so Claude Code can call modeling tools directly.\n');
514
- const claudeSettingsDir = join(targetDir, '.claude');
515
- const settingsPath = join(claudeSettingsDir, 'settings.json');
516
- mkdirSync(claudeSettingsDir, { recursive: true });
722
+ writeFileSync(configPath, JSON.stringify(config, null, 2));
723
+ console.log(`\n Saved to ${relative(targetDir, configPath)}`);
724
+ return config;
725
+ }
517
726
 
518
- let settings = {};
519
- if (existsSync(settingsPath)) {
727
+ // Configure the MCP server registration for a project — split out of `init` into
728
+ // its own command (`init-mcp`) since not every harness/workflow wants an
729
+ // automatic .claude/settings.json edit or an interactive "connect elsewhere?"
730
+ // prompt bundled into scaffolding.
731
+ async function configureMcp(options = {}) {
732
+ const targetDir = process.cwd();
733
+ const effective = loadEffectiveConfig(targetDir, null, options.configPath);
734
+ const baseUrl = effective.config.baseUrl || DEFAULT_BASE_URL;
735
+
736
+ console.log('🔌 Configuring MCP server...');
737
+ const claudeSettingsDir = join(targetDir, '.claude');
738
+ const settingsPath = join(claudeSettingsDir, 'settings.json');
739
+ mkdirSync(claudeSettingsDir, { recursive: true });
740
+
741
+ let settings = {};
742
+ if (existsSync(settingsPath)) {
743
+ try {
744
+ settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
745
+ } catch {
746
+ settings = {};
747
+ }
748
+ }
749
+
750
+ settings.mcpServers = settings.mcpServers || {};
751
+ settings.mcpServers.eventmodelers = {
752
+ type: 'http',
753
+ url: `${baseUrl}/mcp`,
754
+ };
755
+
756
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
757
+ console.log(' ✓ MCP server configured in .claude/settings.json');
758
+
759
+ // Record that MCP was registered so `uninstall` knows to clean up the
760
+ // settings.json entry — checked against every kit dir's manifest.
761
+ for (const dirName of KIT_DIR_NAMES) {
762
+ const manifestPath = join(targetDir, dirName, '.eventmodelers', 'install-manifest.json');
763
+ if (existsSync(manifestPath)) {
764
+ const manifest = readJsonSafe(manifestPath);
765
+ manifest.mcpRegistered = true;
766
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
767
+ }
768
+ }
769
+
770
+ const mcpUrl = `${baseUrl}/mcp`;
771
+ if (options.print) {
772
+ console.log('\nConnect the same MCP server in another harness:');
773
+ for (const client of Object.values(MCP_CLIENTS)) {
774
+ console.log(` ${client.label.padEnd(12)} ${client.command(mcpUrl)}`);
775
+ }
776
+ for (const client of MCP_MANUAL_CLIENTS) {
777
+ console.log(` ${client.label.padEnd(12)} ${client.hint(mcpUrl)}`);
778
+ }
779
+ } else {
780
+ const clientChoice = await selectPrompt('\nConnect the MCP globally to another harness?', [
781
+ { label: 'Skip', value: 'skip' },
782
+ ...Object.entries(MCP_CLIENTS).map(([key, c]) => ({ label: c.label, value: key })),
783
+ ], 0);
784
+
785
+ if (clientChoice !== 'skip') {
786
+ const client = MCP_CLIENTS[clientChoice];
787
+ const cmd = client.command(mcpUrl);
520
788
  try {
521
- settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
789
+ execSync(cmd, { stdio: 'inherit' });
790
+ console.log(` ✓ ${client.label} connected via: ${cmd}`);
522
791
  } catch {
523
- settings = {};
792
+ console.error(` ⚠️ Command failed — you can run it manually:`);
793
+ console.error(` ${cmd}`);
524
794
  }
525
795
  }
526
796
 
527
- const baseUrl = config.baseUrl || 'https://api.eventmodelers.ai';
528
- settings.mcpServers = settings.mcpServers || {};
529
- settings.mcpServers.eventmodelers = {
530
- type: 'http',
531
- url: `${baseUrl}/mcp`,
532
- };
533
-
534
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
535
- console.log(' ✓ MCP server configured in .claude/settings.json');
797
+ if (MCP_MANUAL_CLIENTS.length) {
798
+ console.log('\nOther harnesses without a scriptable installer:');
799
+ MCP_MANUAL_CLIENTS.forEach((c) => console.log(` ${c.label.padEnd(12)} ${c.hint(mcpUrl)}`));
800
+ }
801
+ }
802
+ }
536
803
 
537
- const mcpUrl = `${baseUrl}/mcp`;
538
- if (options.print) {
539
- console.log('\nConnect the same MCP server in another harness:');
540
- for (const client of Object.values(MCP_CLIENTS)) {
541
- console.log(` ${client.label.padEnd(12)} ${client.command(mcpUrl)}`);
542
- }
543
- for (const client of MCP_MANUAL_CLIENTS) {
544
- console.log(` ${client.label.padEnd(12)} ${client.hint(mcpUrl)}`);
545
- }
546
- } else {
547
- const clientChoice = await selectPrompt('\nConnect the MCP globally to another harness?', [
548
- { label: 'Skip', value: 'skip' },
549
- ...Object.entries(MCP_CLIENTS).map(([key, c]) => ({ label: c.label, value: key })),
550
- ], 0);
551
-
552
- if (clientChoice !== 'skip') {
553
- const client = MCP_CLIENTS[clientChoice];
554
- const cmd = client.command(mcpUrl);
555
- try {
556
- execSync(cmd, { stdio: 'inherit' });
557
- console.log(` ✓ ${client.label} connected via: ${cmd}`);
558
- } catch {
559
- console.error(` ⚠️ Command failed — you can run it manually:`);
560
- console.error(` ${cmd}`);
561
- }
804
+ // `run --real-time`: same job as ralph-claude.js (drive the installed kit's ralph
805
+ // loop with Claude as the executor), but instead of spawning a fresh `claude -p`
806
+ // process per task, it keeps ONE process warm across tasks via
807
+ // `--input-format stream-json` and writes each task's prompt to its stdin —
808
+ // verified (see dev notes) to stay alive and accept further turns after a result.
809
+ // This closes the remaining latency gap for voice/live use: the realtime channel
810
+ // + trigger wake in the kit's own lib/ralph.js already deliver new tasks with ~no
811
+ // delay, so the dominant cost left was the cold start (process boot, CLAUDE.md
812
+ // re-read, /connect, skill discovery) a fresh spawn pays every single task.
813
+ //
814
+ // Deliberately NOT a templated file in the kit dir (unlike ralph-claude.js)
815
+ // this is exactly the kind of stack-agnostic runtime plumbing that drifted out of
816
+ // sync when duplicated per stack before (see the "Unify ralph/agent runtime
817
+ // files" commit). It ships once, here, and reuses whatever lib/ralph.js the kit
818
+ // already has installed — that file legitimately differs per kit (modeling-kit's
819
+ // org-wide prompt queue vs. build-kit's per-board slice tracking), so it stays
820
+ // where `init`/`init-modeling` put it.
821
+ async function runRealtime(kitDir, projectDir) {
822
+ const ralphLibPath = join(kitDir, 'lib', 'ralph.js');
823
+ if (!existsSync(ralphLibPath)) {
824
+ console.error(`❌ ${relative(process.cwd(), ralphLibPath)} not found — --real-time needs a kit installed via \`init\`/\`init-modeling\`.`);
825
+ process.exit(1);
826
+ }
827
+ const { startRalph, loadLocalConfig } = await import(pathToFileURL(ralphLibPath).href);
828
+
829
+ const cfg = loadLocalConfig(kitDir);
830
+ const QUESTIONING_RULE =
831
+ 'IMPORTANT: You are running autonomously — no human is available to answer questions. ' +
832
+ 'If you need clarification to proceed, do NOT pause or ask interactively. Instead, post your question ' +
833
+ 'as a QUESTION-type comment (via /handle-comment with action=place and type=QUESTION) on the most ' +
834
+ 'relevant slice or column node on the board, then continue with your best interpretation of the prompt.\n\n';
835
+ const inlineHeader = cfg.boardId
836
+ ? `board=${cfg.boardId} token=${cfg.token} org=${cfg.organizationId} baseUrl=${cfg.baseUrl}\n\n${QUESTIONING_RULE}`
837
+ : QUESTIONING_RULE;
838
+
839
+ const claudeArgs = ['--dangerously-skip-permissions', '-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose'];
840
+ if (cfg.model) claudeArgs.push('--model', cfg.model);
841
+ const claudeEnv = cfg.anthropicBaseUrl ? { ...process.env, ANTHROPIC_BASE_URL: cfg.anthropicBaseUrl } : process.env;
842
+
843
+ let proc = null;
844
+ let stdoutBuffer = '';
845
+ let pending = null; // one in-flight task at a time, matching the ralph loop's own serial processing
846
+ const log = (line) => console.log(`[realtime] ${line}`);
847
+
848
+ // stream-json output loses the normal interactive TUI (tool cards, live diffs) —
849
+ // this is a plain-text approximation, good enough for a headless/voice runner.
850
+ function handleLine(line) {
851
+ if (!line.trim()) return;
852
+ let msg;
853
+ try { msg = JSON.parse(line); } catch { return; }
854
+
855
+ if (msg.type === 'assistant') {
856
+ for (const block of msg.message?.content ?? []) {
857
+ if (block.type === 'text' && block.text) log(block.text);
858
+ if (block.type === 'tool_use') log(`→ ${block.name}`);
562
859
  }
860
+ return;
861
+ }
862
+ if (msg.type === 'result') {
863
+ log(`done (${msg.duration_ms}ms${msg.total_cost_usd ? `, $${msg.total_cost_usd.toFixed(4)}` : ''})`);
864
+ const turn = pending;
865
+ pending = null;
866
+ if (turn) (msg.is_error ? turn.reject(new Error(msg.result || 'Claude turn errored')) : turn.resolve());
867
+ }
868
+ }
563
869
 
564
- if (MCP_MANUAL_CLIENTS.length) {
565
- console.log('\nOther harnesses without a scriptable installer:');
566
- MCP_MANUAL_CLIENTS.forEach((c) => console.log(` ${c.label.padEnd(12)} ${c.hint(mcpUrl)}`));
870
+ function spawnProcess() {
871
+ proc = spawn('claude', claudeArgs, { cwd: projectDir, env: claudeEnv, stdio: ['pipe', 'pipe', 'inherit'] });
872
+ stdoutBuffer = '';
873
+ proc.stdout.on('data', (chunk) => {
874
+ stdoutBuffer += chunk.toString();
875
+ const lines = stdoutBuffer.split('\n');
876
+ stdoutBuffer = lines.pop();
877
+ for (const l of lines) handleLine(l);
878
+ });
879
+ proc.on('exit', (code) => {
880
+ log(`process exited (${code}) — will respawn on next task`);
881
+ proc = null;
882
+ if (pending) {
883
+ const turn = pending;
884
+ pending = null;
885
+ turn.reject(new Error(`claude process exited (${code}) mid-turn`));
567
886
  }
568
- }
887
+ });
888
+ log('warm session started');
889
+ }
569
890
 
570
- // --- 7. Install manifest (drives precise `uninstall` later) ---
571
- // Only the footprint listed here is ever removed by `uninstall` — the root
572
- // scaffold (step 2) is real project source the user builds on, so it's
573
- // deliberately left out and never touched by uninstall.
574
- const manifestDir = join(kitDir, '.eventmodelers');
575
- mkdirSync(manifestDir, { recursive: true });
576
- writeFileSync(
577
- join(manifestDir, 'install-manifest.json'),
578
- JSON.stringify({ stack: stackKey, global: !!options.global, skills: installedSkills, claudeExtras, mcpRegistered: true }, null, 2),
579
- );
891
+ function runClaudeWarm(prompt) {
892
+ if (!proc) spawnProcess();
893
+ return new Promise((resolveTurn, rejectTurn) => {
894
+ pending = { resolve: resolveTurn, reject: rejectTurn };
895
+ proc.stdin.write(JSON.stringify({ type: 'user', message: { role: 'user', content: inlineHeader + prompt } }) + '\n');
896
+ });
897
+ }
580
898
 
581
- console.log('\n✅ Done!\n');
582
- console.log('Start the agent (realtime + task loop in one process):');
583
- console.log(' npx @eventmodelers/cli run\n');
584
- console.log('Or using Ollama (run `ollama serve` first):');
585
- console.log(' npx @eventmodelers/cli run --ollama\n');
586
- console.log('Or using the bash loop only (no realtime):');
587
- console.log(' npx @eventmodelers/cli run --bash\n');
588
- console.log(`Skills are ready in ${options.global ? join(homedir(), '.claude', 'skills') : '.claude/skills/'} — use /connect to set a board ID.\n`);
589
- console.log('💡 Recommended: add Chrome DevTools MCP for browser inspection:');
590
- console.log(' claude mcp add chrome-devtools --scope user -- npx chrome-devtools-mcp@latest\n');
899
+ spawnProcess();
900
+ await startRalph({ kitDir, projectDir, onTask: runClaudeWarm });
591
901
  }
592
902
 
593
903
  const program = new Command();
@@ -599,26 +909,129 @@ program
599
909
  .option('--config <path>', 'Path to an explicit config.json, overriding directory-based resolution (individual fields can also be set via EVENTMODELERS_* env vars, which always win)')
600
910
  .option('--print', 'Print follow-up commands (e.g. claude mcp add) instead of prompting to run them');
601
911
 
602
- program
912
+ // Shared by init/init-modeling/init-config: direct command-line credentials,
913
+ // Protractor-style (--base-url=..., not a generic --param key=value passthrough) —
914
+ // self-documenting in --help and typo-safe. These win over both the config file
915
+ // and EVENTMODELERS_* env vars, same as any explicitly-passed flag should.
916
+ function credentialFlags(cmd) {
917
+ return cmd
918
+ .option('--token <uuid>', 'API token (overrides config file / env var)')
919
+ .option('--board-id <uuid>', 'Board ID (overrides config file / env var)')
920
+ .option('--organization-id <uuid>', 'Organization ID (overrides config file / env var)')
921
+ .option('--base-url <url>', 'Platform base URL (overrides config file / env var)');
922
+ }
923
+
924
+ function credentialOverridesFromOpts(opts) {
925
+ return { token: opts.token, boardId: opts.boardId, organizationId: opts.organizationId, baseUrl: opts.baseUrl };
926
+ }
927
+
928
+ credentialFlags(program
603
929
  .command('init')
604
930
  .alias('install')
605
931
  .description('Scaffold a stack + install the agent modeling kit into the current directory')
606
932
  .option('--stack <name>', `Stack to install (${Object.keys(STACKS).join(', ')})`)
607
- .option('--global', 'Install skills into ~/.claude/skills/ instead of the project — available in every project')
933
+ .option('--global', 'Install skills into ~/.claude/skills/ instead of the project — available in every project'))
608
934
  .action(async (opts, command) => {
609
935
  const stackKey = await resolveStack(opts.stack);
610
936
  const globalOpts = command.optsWithGlobals();
611
- await installStack(stackKey, STACKS[stackKey], { configPath: globalOpts.config, print: globalOpts.print, global: opts.global });
937
+ await installStack(stackKey, STACKS[stackKey], {
938
+ configPath: globalOpts.config,
939
+ print: globalOpts.print,
940
+ global: opts.global,
941
+ credentialOverrides: credentialOverridesFromOpts(opts),
942
+ });
612
943
  });
613
944
 
614
- program
945
+ credentialFlags(program
615
946
  .command('init-modeling')
616
947
  .alias('modeling')
617
948
  .description('Install skills + the agent loop only — no backend scaffold')
618
- .option('--global', 'Install skills into ~/.claude/skills/ instead of the project — available in every project')
949
+ .option('--global', 'Install skills into ~/.claude/skills/ instead of the project — available in every project'))
950
+ .action(async (opts, command) => {
951
+ const globalOpts = command.optsWithGlobals();
952
+ await installStack(MODELING_KIT.key, MODELING_KIT, {
953
+ configPath: globalOpts.config,
954
+ print: globalOpts.print,
955
+ global: opts.global,
956
+ credentialOverrides: credentialOverridesFromOpts(opts),
957
+ });
958
+ });
959
+
960
+ program
961
+ .command('init-mcp')
962
+ .description('Register the eventmodelers MCP server in .claude/settings.json (and optionally another harness)')
619
963
  .action(async (opts, command) => {
620
964
  const globalOpts = command.optsWithGlobals();
621
- await installStack(MODELING_KIT.key, MODELING_KIT, { configPath: globalOpts.config, print: globalOpts.print, global: opts.global });
965
+ await configureMcp({ configPath: globalOpts.config, print: globalOpts.print });
966
+ });
967
+
968
+ program
969
+ .command('init-agents')
970
+ .description(`Expose installed skills to other AI agent hosts (${Object.keys(AGENT_HOSTS).join(', ')}) as thin stub commands pointing at the canonical .claude/skills/ files — no skill content duplicated per host`)
971
+ .option('--hosts <list>', `Comma-separated host keys (${Object.keys(AGENT_HOSTS).join(', ')})`)
972
+ .option('--all', 'Expose to every known host')
973
+ .option('--global', 'Read skills from ~/.claude/skills/ instead of the project')
974
+ .action(async (opts) => {
975
+ const hosts = opts.all
976
+ ? Object.keys(AGENT_HOSTS)
977
+ : opts.hosts
978
+ ? opts.hosts.split(',').map((s) => s.trim()).filter(Boolean)
979
+ : null;
980
+ await configureAgentHosts({ hosts, global: opts.global });
981
+ });
982
+
983
+ credentialFlags(program
984
+ .command('init-config')
985
+ .description('Configure credentials only — writes .eventmodelers/config.json in the current directory, or ~/.eventmodelers/config.json with --global')
986
+ .option('--global', 'Write account-wide defaults (organizationId + token only) to ~/.eventmodelers/config.json instead of the project'))
987
+ .action(async (opts, command) => {
988
+ const globalOpts = command.optsWithGlobals();
989
+ const overrides = credentialOverridesFromOpts(opts);
990
+
991
+ if (opts.global) {
992
+ // Deliberately narrower than a project config: a board is specific to one
993
+ // project, and baseUrl already has its own runtime default, so the only
994
+ // things worth defaulting across every project are your identity (org)
995
+ // and how you authenticate (token).
996
+ const configPath = join(homedir(), '.eventmodelers', 'config.json');
997
+ const requiredFields = ['organizationId', 'token'];
998
+ const existing = readJsonSafe(configPath);
999
+ const base = { organizationId: existing.organizationId, token: existing.token };
1000
+ if (overrides.organizationId) base.organizationId = overrides.organizationId;
1001
+ if (overrides.token) base.token = overrides.token;
1002
+
1003
+ const configured = await configureCredentials({
1004
+ config: base,
1005
+ configPath,
1006
+ targetDir: homedir(),
1007
+ requiredFields,
1008
+ boardIdOptional: true,
1009
+ overrides: {},
1010
+ print: globalOpts.print,
1011
+ skipGitignore: true,
1012
+ });
1013
+
1014
+ // configureCredentials' generic paste/manual flow may have picked up
1015
+ // boardId/baseUrl too (e.g. from a pasted JSON blob) — strip them back out
1016
+ // before the final write, since --global only ever persists identity.
1017
+ writeFileSync(configPath, JSON.stringify({ organizationId: configured.organizationId, token: configured.token }, null, 2));
1018
+ console.log(`\n ✓ Saved account-wide defaults to ${configPath}`);
1019
+ } else {
1020
+ const targetDir = process.cwd();
1021
+ const configPath = globalOpts.config
1022
+ ? resolve(targetDir, globalOpts.config)
1023
+ : join(targetDir, '.eventmodelers', 'config.json');
1024
+ const effective = loadEffectiveConfig(targetDir, null, globalOpts.config);
1025
+ await configureCredentials({
1026
+ config: effective.config,
1027
+ configPath,
1028
+ targetDir,
1029
+ requiredFields: ['organizationId', 'token'],
1030
+ boardIdOptional: true,
1031
+ overrides,
1032
+ print: globalOpts.print,
1033
+ });
1034
+ }
622
1035
  });
623
1036
 
624
1037
  program
@@ -626,7 +1039,8 @@ program
626
1039
  .description('Start the agent loop from the installed kit dir (default: ralph-claude.js)')
627
1040
  .option('--ollama', 'Use ralph-ollama.js instead of the default Claude runner')
628
1041
  .option('--bash', 'Use the bash-only ralph.sh loop (no realtime)')
629
- .action((opts) => {
1042
+ .option('--real-time', 'Keep one Claude process warm across tasks instead of spawning a fresh one per task, for low-latency voice/live use. Built into the CLI, not a per-project file.')
1043
+ .action(async (opts) => {
630
1044
  const cwd = process.cwd();
631
1045
  const kitDir = findInstalledKitDir(cwd);
632
1046
  if (!kitDir) {
@@ -634,6 +1048,23 @@ program
634
1048
  process.exit(1);
635
1049
  }
636
1050
 
1051
+ const pickedCount = [opts.bash, opts.ollama, opts.realTime].filter(Boolean).length;
1052
+ if (pickedCount > 1) {
1053
+ console.error('❌ --bash, --ollama, and --real-time are mutually exclusive — pick one.');
1054
+ process.exit(1);
1055
+ }
1056
+
1057
+ if (opts.realTime) {
1058
+ console.log(`▶ Starting real-time loop (warm Claude process) for ${relative(cwd, kitDir)}...\n`);
1059
+ try {
1060
+ await runRealtime(kitDir, resolve(kitDir, '..'));
1061
+ } catch (err) {
1062
+ console.error('[realtime] Fatal:', err);
1063
+ process.exit(1);
1064
+ }
1065
+ return;
1066
+ }
1067
+
637
1068
  // The actual agent loop lives in the scaffolded kit dir, not in this package — this
638
1069
  // is just a thin dispatcher so users don't have to remember the kit-dir name or which
639
1070
  // runner file to invoke. Users (and the agent itself, via AGENT.md) may customize these
@@ -694,6 +1125,33 @@ function uninstallKitDir(kitDir, cwd) {
694
1125
  }
695
1126
  }
696
1127
 
1128
+ if (manifest.agentHostFiles?.length) {
1129
+ const touchedDirs = new Set();
1130
+ for (const relPath of manifest.agentHostFiles) {
1131
+ const p = join(cwd, relPath);
1132
+ if (existsSync(p)) {
1133
+ rmSync(p, { force: true });
1134
+ console.log(` ✓ Removed ${relPath}`);
1135
+ touchedDirs.add(dirname(p));
1136
+ }
1137
+ }
1138
+ // Prune now-empty host/package directories (e.g. .cursor/commands/,
1139
+ // .agents/skills/eventmodelers.timeline/) so uninstall doesn't leave an
1140
+ // empty dotfile forest behind — but never walk above cwd.
1141
+ for (const dir of touchedDirs) {
1142
+ let d = dir;
1143
+ while (d.startsWith(cwd) && d !== cwd) {
1144
+ try {
1145
+ if (readdirSync(d).length > 0) break;
1146
+ rmSync(d, { recursive: true, force: true });
1147
+ d = dirname(d);
1148
+ } catch {
1149
+ break;
1150
+ }
1151
+ }
1152
+ }
1153
+ }
1154
+
697
1155
  if (manifest.mcpRegistered) {
698
1156
  const settingsPath = join(cwd, '.claude', 'settings.json');
699
1157
  if (existsSync(settingsPath)) {
@@ -719,7 +1177,7 @@ function uninstallKitDir(kitDir, cwd) {
719
1177
 
720
1178
  program
721
1179
  .command('uninstall')
722
- .description('Remove everything init/init-modeling installed: the kit dir, the skills it copied (project-local or ~/.claude/skills with --global), and its MCP entry in .claude/settings.json. Leaves the root project scaffold untouched.')
1180
+ .description('Remove everything init/init-modeling installed: the kit dir, the skills it copied (project-local or ~/.claude/skills with --global), its MCP entry in .claude/settings.json, and any files written by init-agents. Leaves the root project scaffold untouched.')
723
1181
  .option('--build-kit', `Remove ${STACKS.node.kitDirName}/ (the backend-stack kit dir)`)
724
1182
  .option('--modeling-kit', `Remove ${MODELING_KIT.kitDirName}/ (the modeling-only kit dir)`)
725
1183
  .action((opts) => {
@@ -772,7 +1230,7 @@ program
772
1230
  console.log(`Ralph agent: ${ralphPath && existsSync(ralphPath) ? '✅ present' : '❌ missing'}`);
773
1231
 
774
1232
  if (sources.length) {
775
- console.log(`\nConnected to: ${cfg.baseUrl || 'https://api.eventmodelers.ai'}`);
1233
+ console.log(`\nConnected to: ${cfg.baseUrl || DEFAULT_BASE_URL}`);
776
1234
  console.log(`Organization: ${cfg.organizationId}`);
777
1235
  if (cfg.boardId) console.log(`Board: ${cfg.boardId}`);
778
1236
  console.log(`\nConfig source${sources.length > 1 ? 's (later overrides earlier)' : ''}:`);