@eventmodelers/cli 0.0.7 → 0.0.9

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 +614 -149
  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,11 +270,31 @@ 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
+
159
278
  // Canonical order the account page pastes values in, regardless of which fields a
160
279
  // given stack actually requires — a modeling-kit install (no boardId required) still
161
280
  // gets a paste containing all 4 fields, so we must not drop the ones we don't need.
162
281
  const PASTE_FIELD_ORDER = ['organizationId', 'boardId', 'token'];
163
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
+
164
298
  // Accepts either a JSON object (as copied from the account page) or a comma-separated
165
299
  // line of values, in PASTE_FIELD_ORDER, with an optional base URL anywhere in the list.
166
300
  function parseCredentialsPaste(text, requiredFields) {
@@ -170,11 +304,18 @@ function parseCredentialsPaste(text, requiredFields) {
170
304
  try {
171
305
  const obj = JSON.parse(trimmed);
172
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
+
173
313
  const result = {};
174
- if (obj.organizationId || obj.orgId) result.organizationId = obj.organizationId || obj.orgId;
175
- if (obj.boardId) result.boardId = obj.boardId;
176
- if (obj.token) result.token = obj.token;
177
- 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
+ }
178
319
  if (requiredFields.every((f) => result[f])) return result;
179
320
  return null;
180
321
  }
@@ -186,18 +327,26 @@ function parseCredentialsPaste(text, requiredFields) {
186
327
  const result = {};
187
328
  const remaining = [];
188
329
  for (const v of values) {
189
- 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;
190
336
  else remaining.push(v);
191
337
  }
192
- if (remaining.length < requiredFields.length) return null;
338
+ if (remaining.length < requiredFields.length - Object.keys(result).length) return null;
193
339
  // If more values were pasted than this stack strictly requires (e.g. a boardId
194
340
  // in a modeling-kit paste), use the full canonical order so the extra field is
195
341
  // still captured instead of being mis-zipped against the shorter requiredFields
196
342
  // list and silently dropped/misassigned.
197
343
  const fieldOrder = remaining.length > requiredFields.length ? PASTE_FIELD_ORDER : requiredFields;
198
- fieldOrder.forEach((field, i) => {
344
+ let i = 0;
345
+ for (const field of fieldOrder) {
346
+ if (result[field]) continue; // already resolved via an embedded "field=" prefix
199
347
  if (remaining[i] !== undefined) result[field] = remaining[i];
200
- });
348
+ i++;
349
+ }
201
350
 
202
351
  return requiredFields.every((f) => result[f]) ? result : null;
203
352
  }
@@ -263,9 +412,15 @@ function findConfigInParents(startDir) {
263
412
  const candidate = join(dir, '.eventmodelers', 'config.json');
264
413
  if (existsSync(candidate)) return candidate;
265
414
  const parent = dirname(dir);
266
- if (parent === dir) return null;
415
+ if (parent === dir) break;
267
416
  dir = parent;
268
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;
269
424
  }
270
425
 
271
426
  function readJsonSafe(path) {
@@ -356,6 +511,12 @@ async function installStack(stackKey, stackCfg, options = {}) {
356
511
  const targetDir = process.cwd();
357
512
  const templatesSource = join(__dirname, 'stacks', stackKey, 'templates');
358
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');
359
520
 
360
521
  if (!existsSync(templatesSource)) {
361
522
  console.error('❌ Templates directory not found at:', templatesSource);
@@ -366,16 +527,21 @@ async function installStack(stackKey, stackCfg, options = {}) {
366
527
  // Recorded into the install manifest (step 8) so `uninstall` can remove exactly
367
528
  // these files later and nothing the user added independently.
368
529
  const claudeSkillsSrc = join(templatesSource, '.claude', 'skills');
369
- const installedSkills = existsSync(claudeSkillsSrc) ? readdirSync(claudeSkillsSrc) : [];
530
+ const installedSkills = [
531
+ ...(existsSync(sharedSkills) ? readdirSync(sharedSkills) : []),
532
+ ...(existsSync(claudeSkillsSrc) ? readdirSync(claudeSkillsSrc) : []),
533
+ ];
370
534
  let claudeExtras = [];
371
535
 
372
536
  if (options.global) {
373
537
  const globalSkillsDir = join(homedir(), '.claude', 'skills');
374
538
  console.log('📦 Installing skills globally...');
539
+ copyDirContents(sharedSkills, globalSkillsDir);
375
540
  copyDirContents(claudeSkillsSrc, globalSkillsDir);
376
541
  } else {
377
542
  console.log('📦 Installing skills...');
378
543
  copyDirContents(join(templatesSource, '.claude'), join(targetDir, '.claude'));
544
+ copyDirContents(sharedSkills, join(targetDir, '.claude', 'skills'));
379
545
  claudeExtras = existsSync(join(templatesSource, '.claude'))
380
546
  ? readdirSync(join(templatesSource, '.claude')).filter((f) => f !== 'skills')
381
547
  : [];
@@ -426,9 +592,60 @@ async function installStack(stackKey, stackCfg, options = {}) {
426
592
  const configPath = options.configPath
427
593
  ? resolve(targetDir, options.configPath)
428
594
  : join(targetDir, '.eventmodelers', 'config.json');
429
- const configDir = dirname(configPath);
430
- mkdirSync(configDir, { recursive: true });
431
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, force = 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) {
432
649
  const gitignorePath = join(targetDir, '.gitignore');
433
650
  const relConfigDir = relative(targetDir, configDir);
434
651
  if (relConfigDir && !relConfigDir.startsWith('..')) {
@@ -442,147 +659,245 @@ async function installStack(stackKey, stackCfg, options = {}) {
442
659
  writeFileSync(gitignorePath, `${gitignoreEntry}\n`);
443
660
  }
444
661
  }
662
+ }
445
663
 
446
- const requiredFields = stackCfg.needsBoardId
447
- ? ['organizationId', 'boardId', 'token']
448
- : ['organizationId', 'token'];
449
-
450
- const effective = loadEffectiveConfig(targetDir, kitDir, options.configPath);
451
- let config = effective.config;
452
- if (effective.sources.length > 1) {
453
- console.log(`\n ✓ Found shared defaults in ${effective.sources[0]}`);
454
- }
455
- const hasConfig = requiredFields.every((f) => config[f]);
456
-
457
- const stillMissing = requiredFields.some((f) => !config[f]);
458
- if (stillMissing && options.print) {
459
- console.log('\n ℹ️ --print skipping credential prompt, missing fields must be set via EVENTMODELERS_* env vars or config.json');
460
- } else if (stillMissing) {
461
- const choice = await selectPrompt('How do you want to configure credentials?', [
462
- { label: 'Paste values copied from app.eventmodelers.ai/account', value: 'paste' },
463
- { label: 'Enter values one by one', value: 'manual' },
464
- { label: 'Get instructions for configuring later', value: 'instructions' },
465
- { label: 'Skip for now', value: 'skip' },
466
- ], 1);
467
-
468
- if (choice === 'paste') {
469
- console.log('\n Copy your credentials from https://app.eventmodelers.ai/account,');
470
- console.log(' then paste them below and press Enter:\n');
471
- const pasted = await promptPasteBlock();
472
- const parsed = parseCredentialsPaste(pasted, requiredFields);
473
- if (parsed) {
474
- config = { ...config, ...parsed };
475
- writeFileSync(configPath, JSON.stringify(config, null, 2));
476
- console.log(`\n ✓ Saved to ${relative(targetDir, configPath)}`);
477
- } else {
478
- console.log(`\n ⚠️ Couldn't make sense of that paste — nothing was saved.`);
479
- console.log(` Paste it into ${relative(targetDir, configPath)} yourself, or use /connect later.`);
480
- }
481
- } else if (choice === 'manual') {
482
- console.log('\n🔑 Enter your Eventmodelers credentials:\n');
483
- config.organizationId = await prompt(' Organization ID: ');
484
- // Always ask, even when this stack doesn't strictly require it — it's still
485
- // used as a fallback default by the agent loop (see BOARD_ID resolution).
486
- const boardId = await prompt(` Board ID${stackCfg.needsBoardId ? '' : ' (optional)'}: `);
487
- if (boardId) config.boardId = boardId;
488
- config.token = await prompt(' Token: ');
664
+ const stillMissing = force || 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;
489
683
  writeFileSync(configPath, JSON.stringify(config, null, 2));
490
- console.log(`\n ✓ Credentials saved to ${relative(targetDir, configPath)}`);
491
- } else if (choice === 'instructions') {
492
- console.log(`\n Paste your credentials into:\n`);
493
- console.log(` ${configPath}`);
494
- console.log(`\n (or any ancestor directory's .eventmodelers/config.json, e.g. ~/.eventmodelers/config.json`);
495
- console.log(` to share the same credentials across multiple projects)\n`);
496
- console.log(` The file should look like:`);
497
- const sample = ` {\n "token": "...",\n "boardId": "...",\n "organizationId": "...",\n "baseUrl": "https://api.eventmodelers.ai"\n }\n`;
498
- console.log(sample);
499
- console.log(' Then re-run this installer, or just run the agent afterwards.\n');
684
+ console.log(`\n ✓ Saved to ${relative(targetDir, configPath)}`);
500
685
  } else {
501
- 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.`);
502
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');
503
709
  } else {
504
- console.log('\n Config already present skipping credential prompt');
710
+ console.log('\n ℹ️ Skipped use /connect in Claude Code to add credentials later');
505
711
  }
712
+ } else {
713
+ console.log('\n ✓ Config already present — skipping credential prompt');
714
+ }
506
715
 
507
- // Claude vs. Ollama, and any custom Anthropic-compatible base URL, is a `run`-time
508
- // choice (`eventmodelers run` vs `--ollama`) not an install-time one. Power users
509
- // can still pin config.anthropicBaseUrl/model via EVENTMODELERS_ANTHROPIC_BASE_URL /
510
- // EVENTMODELERS_MODEL env vars or by editing config.json directly; loadEffectiveConfig
511
- // already picks those up above, so nothing further to do here.
512
- writeFileSync(configPath, JSON.stringify(config, null, 2));
513
- 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
+ }
514
721
 
515
- // --- 6. MCP server in .claude/settings.json ---
516
- console.log('🔌 Configuring MCP server...');
517
- const claudeSettingsDir = join(targetDir, '.claude');
518
- const settingsPath = join(claudeSettingsDir, 'settings.json');
519
- 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
+ }
520
726
 
521
- let settings = {};
522
- 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);
523
788
  try {
524
- settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
789
+ execSync(cmd, { stdio: 'inherit' });
790
+ console.log(` ✓ ${client.label} connected via: ${cmd}`);
525
791
  } catch {
526
- settings = {};
792
+ console.error(` ⚠️ Command failed — you can run it manually:`);
793
+ console.error(` ${cmd}`);
527
794
  }
528
795
  }
529
796
 
530
- const baseUrl = config.baseUrl || 'https://api.eventmodelers.ai';
531
- settings.mcpServers = settings.mcpServers || {};
532
- settings.mcpServers.eventmodelers = {
533
- type: 'http',
534
- url: `${baseUrl}/mcp`,
535
- };
536
-
537
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
538
- 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
+ }
539
803
 
540
- const mcpUrl = `${baseUrl}/mcp`;
541
- if (options.print) {
542
- console.log('\nConnect the same MCP server in another harness:');
543
- for (const client of Object.values(MCP_CLIENTS)) {
544
- console.log(` ${client.label.padEnd(12)} ${client.command(mcpUrl)}`);
545
- }
546
- for (const client of MCP_MANUAL_CLIENTS) {
547
- console.log(` ${client.label.padEnd(12)} ${client.hint(mcpUrl)}`);
548
- }
549
- } else {
550
- const clientChoice = await selectPrompt('\nConnect the MCP globally to another harness?', [
551
- { label: 'Skip', value: 'skip' },
552
- ...Object.entries(MCP_CLIENTS).map(([key, c]) => ({ label: c.label, value: key })),
553
- ], 0);
554
-
555
- if (clientChoice !== 'skip') {
556
- const client = MCP_CLIENTS[clientChoice];
557
- const cmd = client.command(mcpUrl);
558
- try {
559
- execSync(cmd, { stdio: 'inherit' });
560
- console.log(` ✓ ${client.label} connected via: ${cmd}`);
561
- } catch {
562
- console.error(` ⚠️ Command failed — you can run it manually:`);
563
- console.error(` ${cmd}`);
564
- }
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}`);
565
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
+ }
566
869
 
567
- if (MCP_MANUAL_CLIENTS.length) {
568
- console.log('\nOther harnesses without a scriptable installer:');
569
- 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`));
570
886
  }
571
- }
887
+ });
888
+ log('warm session started');
889
+ }
572
890
 
573
- // --- 7. Install manifest (drives precise `uninstall` later) ---
574
- // Only the footprint listed here is ever removed by `uninstall` — the root
575
- // scaffold (step 2) is real project source the user builds on, so it's
576
- // deliberately left out and never touched by uninstall.
577
- const manifestDir = join(kitDir, '.eventmodelers');
578
- mkdirSync(manifestDir, { recursive: true });
579
- writeFileSync(
580
- join(manifestDir, 'install-manifest.json'),
581
- JSON.stringify({ stack: stackKey, global: !!options.global, skills: installedSkills, claudeExtras, mcpRegistered: true }, null, 2),
582
- );
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
+ }
583
898
 
584
- console.log('\n✅ Done! Start your agent:\n');
585
- console.log(' npx @eventmodelers/cli run (--ollama or --bash for other runners)\n');
899
+ spawnProcess();
900
+ await startRalph({ kitDir, projectDir, onTask: runClaudeWarm });
586
901
  }
587
902
 
588
903
  const program = new Command();
@@ -594,26 +909,131 @@ program
594
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)')
595
910
  .option('--print', 'Print follow-up commands (e.g. claude mcp add) instead of prompting to run them');
596
911
 
597
- 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
598
929
  .command('init')
599
930
  .alias('install')
600
931
  .description('Scaffold a stack + install the agent modeling kit into the current directory')
601
932
  .option('--stack <name>', `Stack to install (${Object.keys(STACKS).join(', ')})`)
602
- .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'))
603
934
  .action(async (opts, command) => {
604
935
  const stackKey = await resolveStack(opts.stack);
605
936
  const globalOpts = command.optsWithGlobals();
606
- 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
+ });
607
943
  });
608
944
 
609
- program
945
+ credentialFlags(program
610
946
  .command('init-modeling')
611
947
  .alias('modeling')
612
948
  .description('Install skills + the agent loop only — no backend scaffold')
613
- .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)')
963
+ .action(async (opts, command) => {
964
+ const globalOpts = command.optsWithGlobals();
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'))
614
987
  .action(async (opts, command) => {
615
988
  const globalOpts = command.optsWithGlobals();
616
- await installStack(MODELING_KIT.key, MODELING_KIT, { configPath: globalOpts.config, print: globalOpts.print, global: opts.global });
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
+ force: true,
1013
+ });
1014
+
1015
+ // configureCredentials' generic paste/manual flow may have picked up
1016
+ // boardId/baseUrl too (e.g. from a pasted JSON blob) — strip them back out
1017
+ // before the final write, since --global only ever persists identity.
1018
+ writeFileSync(configPath, JSON.stringify({ organizationId: configured.organizationId, token: configured.token }, null, 2));
1019
+ console.log(`\n ✓ Saved account-wide defaults to ${configPath}`);
1020
+ } else {
1021
+ const targetDir = process.cwd();
1022
+ const configPath = globalOpts.config
1023
+ ? resolve(targetDir, globalOpts.config)
1024
+ : join(targetDir, '.eventmodelers', 'config.json');
1025
+ const effective = loadEffectiveConfig(targetDir, null, globalOpts.config);
1026
+ await configureCredentials({
1027
+ config: effective.config,
1028
+ configPath,
1029
+ targetDir,
1030
+ requiredFields: ['organizationId', 'token'],
1031
+ boardIdOptional: true,
1032
+ overrides,
1033
+ print: globalOpts.print,
1034
+ force: true,
1035
+ });
1036
+ }
617
1037
  });
618
1038
 
619
1039
  program
@@ -621,7 +1041,8 @@ program
621
1041
  .description('Start the agent loop from the installed kit dir (default: ralph-claude.js)')
622
1042
  .option('--ollama', 'Use ralph-ollama.js instead of the default Claude runner')
623
1043
  .option('--bash', 'Use the bash-only ralph.sh loop (no realtime)')
624
- .action((opts) => {
1044
+ .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.')
1045
+ .action(async (opts) => {
625
1046
  const cwd = process.cwd();
626
1047
  const kitDir = findInstalledKitDir(cwd);
627
1048
  if (!kitDir) {
@@ -629,6 +1050,23 @@ program
629
1050
  process.exit(1);
630
1051
  }
631
1052
 
1053
+ const pickedCount = [opts.bash, opts.ollama, opts.realTime].filter(Boolean).length;
1054
+ if (pickedCount > 1) {
1055
+ console.error('❌ --bash, --ollama, and --real-time are mutually exclusive — pick one.');
1056
+ process.exit(1);
1057
+ }
1058
+
1059
+ if (opts.realTime) {
1060
+ console.log(`▶ Starting real-time loop (warm Claude process) for ${relative(cwd, kitDir)}...\n`);
1061
+ try {
1062
+ await runRealtime(kitDir, resolve(kitDir, '..'));
1063
+ } catch (err) {
1064
+ console.error('[realtime] Fatal:', err);
1065
+ process.exit(1);
1066
+ }
1067
+ return;
1068
+ }
1069
+
632
1070
  // The actual agent loop lives in the scaffolded kit dir, not in this package — this
633
1071
  // is just a thin dispatcher so users don't have to remember the kit-dir name or which
634
1072
  // runner file to invoke. Users (and the agent itself, via AGENT.md) may customize these
@@ -689,6 +1127,33 @@ function uninstallKitDir(kitDir, cwd) {
689
1127
  }
690
1128
  }
691
1129
 
1130
+ if (manifest.agentHostFiles?.length) {
1131
+ const touchedDirs = new Set();
1132
+ for (const relPath of manifest.agentHostFiles) {
1133
+ const p = join(cwd, relPath);
1134
+ if (existsSync(p)) {
1135
+ rmSync(p, { force: true });
1136
+ console.log(` ✓ Removed ${relPath}`);
1137
+ touchedDirs.add(dirname(p));
1138
+ }
1139
+ }
1140
+ // Prune now-empty host/package directories (e.g. .cursor/commands/,
1141
+ // .agents/skills/eventmodelers.timeline/) so uninstall doesn't leave an
1142
+ // empty dotfile forest behind — but never walk above cwd.
1143
+ for (const dir of touchedDirs) {
1144
+ let d = dir;
1145
+ while (d.startsWith(cwd) && d !== cwd) {
1146
+ try {
1147
+ if (readdirSync(d).length > 0) break;
1148
+ rmSync(d, { recursive: true, force: true });
1149
+ d = dirname(d);
1150
+ } catch {
1151
+ break;
1152
+ }
1153
+ }
1154
+ }
1155
+ }
1156
+
692
1157
  if (manifest.mcpRegistered) {
693
1158
  const settingsPath = join(cwd, '.claude', 'settings.json');
694
1159
  if (existsSync(settingsPath)) {
@@ -714,7 +1179,7 @@ function uninstallKitDir(kitDir, cwd) {
714
1179
 
715
1180
  program
716
1181
  .command('uninstall')
717
- .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.')
1182
+ .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.')
718
1183
  .option('--build-kit', `Remove ${STACKS.node.kitDirName}/ (the backend-stack kit dir)`)
719
1184
  .option('--modeling-kit', `Remove ${MODELING_KIT.kitDirName}/ (the modeling-only kit dir)`)
720
1185
  .action((opts) => {
@@ -767,7 +1232,7 @@ program
767
1232
  console.log(`Ralph agent: ${ralphPath && existsSync(ralphPath) ? '✅ present' : '❌ missing'}`);
768
1233
 
769
1234
  if (sources.length) {
770
- console.log(`\nConnected to: ${cfg.baseUrl || 'https://api.eventmodelers.ai'}`);
1235
+ console.log(`\nConnected to: ${cfg.baseUrl || DEFAULT_BASE_URL}`);
771
1236
  console.log(`Organization: ${cfg.organizationId}`);
772
1237
  if (cfg.boardId) console.log(`Board: ${cfg.boardId}`);
773
1238
  console.log(`\nConfig source${sources.length > 1 ? 's (later overrides earlier)' : ''}:`);