@eventmodelers/cli 0.0.23 → 0.0.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -225,6 +225,9 @@ npx @eventmodelers/cli run --ollama # same, via local Ollama (ra
225
225
  npx @eventmodelers/cli run --bash # bash-only loop, no realtime (ralph.sh)
226
226
  npx @eventmodelers/cli listen # start the code-export listener (code-export.mjs) from the installed kit dir
227
227
  npx @eventmodelers/cli listen --port 4000 # same, on a different port
228
+ npx @eventmodelers/cli fetch # pull full slice detail from every context on the board into <kit-dir>/.slices/
229
+ npx @eventmodelers/cli fetch --slice-id <id> # same, then print just that slice
230
+ npx @eventmodelers/cli fetch --slice-title <title> # same, then print just the slice matching this title
228
231
  npx @eventmodelers/cli stacks # list available stacks
229
232
  npx @eventmodelers/cli status # check what's installed
230
233
  npx @eventmodelers/cli config # print the fully resolved config (file + env), token masked
@@ -235,6 +238,8 @@ npx @eventmodelers/cli uninstall # remove everything init/ini
235
238
 
236
239
  `listen` is the same kind of dispatcher, but for `<kit-dir>/code-export.mjs` — a local HTTP server (port 3001 by default) that the eventmodelers board UI posts slice/screen data to, which then gets written under `<kit-dir>/.slices/`.
237
240
 
241
+ `fetch` is the pull-based counterpart to `listen`: instead of waiting for the board UI to push data to a running listener, it lists every `MODEL_CONTEXT` node on the board, calls `slicedata?contextId=<id>` for each (full slice detail — commands/events/readmodels/screens/processors/specifications/comments), and writes the same `.slices/<context>/<slice>/slice.json`, `index.json`, and `context.json` layout — useful in CI or any context where nothing is listening on a port. It does not fetch screen images (those only arrive via `listen`'s push). If credentials are missing, it prompts the same way `init-config` does. `--slice-id`/`--slice-title` still fetch and persist everything, then just print the one you asked about.
242
+
238
243
  ### Uninstall
239
244
 
240
245
  Every `init`/`init-modeling` run writes an install manifest into `<kit-dir>/.eventmodelers/install-manifest.json` recording exactly what it put down. `uninstall` reads that manifest back and removes only:
package/cli.js CHANGED
@@ -18,6 +18,7 @@ import { execSync, spawn } from 'child_process';
18
18
  import { createInterface, emitKeypressEvents, moveCursor, clearScreenDown } from 'readline';
19
19
  import { homedir } from 'os';
20
20
  import { randomUUID } from 'crypto';
21
+ import { runFetch, FetchAuthError } from './lib/fetch.js';
21
22
 
22
23
  const __filename = fileURLToPath(import.meta.url);
23
24
  const __dirname = dirname(__filename);
@@ -1453,6 +1454,61 @@ program
1453
1454
  }
1454
1455
  });
1455
1456
 
1457
+ program
1458
+ .command('fetch')
1459
+ .description('Pull full slice detail from every context on the board via the slicedata API and write it into .slices/ — the pull-based counterpart to `listen`, without screen images')
1460
+ .option('--slice-id <id>', 'After fetching, print just the slice with this id')
1461
+ .option('--slice-title <title>', 'After fetching, print just the slice with this title (case-insensitive)')
1462
+ .action(async (opts, command) => {
1463
+ const cwd = process.cwd();
1464
+ const kitDir = findInstalledKitDir(cwd);
1465
+ const globalOpts = command.optsWithGlobals();
1466
+ const explicitConfig = globalOpts.config;
1467
+ const effective = loadEffectiveConfig(cwd, kitDir, explicitConfig);
1468
+ let cfg = effective.config;
1469
+
1470
+ // Same default (project-root .eventmodelers/config.json, or --config) that
1471
+ // installStack uses — kept identical rather than deriving a path from
1472
+ // `effective`, which can point at a kit-dir-scoped config instead.
1473
+ const configPath = explicitConfig ? resolve(cwd, explicitConfig) : join(cwd, '.eventmodelers', 'config.json');
1474
+ const requiredFields = ['organizationId', 'boardId', 'token'];
1475
+
1476
+ // Same prompt (paste/manual/instructions/skip) `install`/`init-config` use —
1477
+ // reusing it here means `fetch` also works as a first-run credential setup.
1478
+ // Also re-entered below if the API rejects whatever we already had.
1479
+ async function promptForCredentials() {
1480
+ cfg = await configureCredentials({
1481
+ config: cfg,
1482
+ configPath,
1483
+ targetDir: cwd,
1484
+ requiredFields,
1485
+ boardIdOptional: false,
1486
+ overrides: {},
1487
+ print: globalOpts.print,
1488
+ });
1489
+ if (requiredFields.some((f) => !cfg[f])) {
1490
+ console.error('❌ Still missing token/organizationId/boardId — re-run `eventmodelers fetch` once configured.');
1491
+ process.exit(1);
1492
+ }
1493
+ }
1494
+
1495
+ if (requiredFields.some((f) => !cfg[f])) await promptForCredentials();
1496
+
1497
+ try {
1498
+ await runFetch({ cwd, kitDir, cfg, opts });
1499
+ } catch (err) {
1500
+ if (!(err instanceof FetchAuthError)) throw err;
1501
+ // Present but wrong, not missing — the connect skill's Step 4 (Verify) treats
1502
+ // 401/403/404 the same way: clear the field that's implicated and re-prompt,
1503
+ // rather than leaving the caller stuck re-running with the same bad value.
1504
+ console.error(`❌ ${err.message}`);
1505
+ if (err.status === 404) delete cfg.boardId;
1506
+ else delete cfg.token;
1507
+ await promptForCredentials();
1508
+ await runFetch({ cwd, kitDir, cfg, opts });
1509
+ }
1510
+ });
1511
+
1456
1512
  program
1457
1513
  .command('stacks')
1458
1514
  .description('List available stacks (for `init --stack`)')
package/lib/fetch.js ADDED
@@ -0,0 +1,182 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
2
+ import { join, relative } from 'path';
3
+
4
+ const DEFAULT_BASE_URL = 'https://api.eventmodelers.ai';
5
+
6
+ // Thrown instead of exiting on 401/403/404 — these mean the *credentials* (not the
7
+ // network/server) are the problem, so the caller gets a chance to re-prompt and retry
8
+ // instead of just dying, the same way the connect skill's Step 4 (Verify) reacts to
9
+ // each status. Other statuses (500, etc.) still exit directly — reconfiguring
10
+ // credentials wouldn't fix those.
11
+ export class FetchAuthError extends Error {
12
+ constructor(status, message) {
13
+ super(message);
14
+ this.status = status;
15
+ }
16
+ }
17
+
18
+ function readJsonSafe(path) {
19
+ if (!path || !existsSync(path)) return {};
20
+ try {
21
+ return JSON.parse(readFileSync(path, 'utf-8'));
22
+ } catch {
23
+ return {};
24
+ }
25
+ }
26
+
27
+ // Mirrors shared/build-kit/code-export.mjs's slugify — kept in sync by hand since
28
+ // that file is copied verbatim into every stack's kit dir and isn't importable here.
29
+ function slugify(text) {
30
+ return text
31
+ .toString()
32
+ .toLowerCase()
33
+ .trim()
34
+ .replace(/\s+/g, '-')
35
+ .replace(/[^\w\-]+/g, '')
36
+ .replace(/\-\-+/g, '-')
37
+ .replace(/^-+/, '')
38
+ .replace(/-+$/, '');
39
+ }
40
+
41
+ // Same folder-naming rule code-export.mjs applies to a slice title when writing
42
+ // .slices/<context>/<folder>/slice.json — kept identical so `fetch` and `listen`
43
+ // produce interchangeable output.
44
+ function sliceFolderName(title) {
45
+ return (title ?? '').replaceAll(' ', '').replaceAll('slice:', '').toLowerCase();
46
+ }
47
+
48
+ // Pulls full slice detail from every context on a board and writes it into
49
+ // .slices/, mirroring the layout code-export.mjs's /api/generate handler produces
50
+ // (minus screen images, which only ever arrive via that push-based listener).
51
+ //
52
+ // { cwd, kitDir, cfg: { token, organizationId, boardId, baseUrl }, opts: { sliceId?, sliceTitle? } }
53
+ export async function runFetch({ cwd, kitDir, cfg, opts = {} }) {
54
+ const baseUrl = cfg.baseUrl || DEFAULT_BASE_URL;
55
+ const headers = { 'x-token': cfg.token, 'x-board-id': cfg.boardId, 'x-user-id': 'cli-fetch' };
56
+
57
+ async function fetchJson(url, what) {
58
+ let res;
59
+ try {
60
+ res = await fetch(url, { headers });
61
+ } catch (err) {
62
+ console.error(`❌ Request failed (${what}): ${err.message}`);
63
+ process.exit(1);
64
+ }
65
+ if (res.status === 401) throw new FetchAuthError(401, `${what}: invalid or expired token`);
66
+ if (res.status === 403) throw new FetchAuthError(403, `${what}: token's organization does not match this board`);
67
+ if (res.status === 404) throw new FetchAuthError(404, `${what}: board not found`);
68
+ if (!res.ok) {
69
+ console.error(`❌ ${what}: HTTP ${res.status}`);
70
+ process.exit(1);
71
+ }
72
+ return res.json();
73
+ }
74
+
75
+ console.log(`▶ Fetching slices from ${baseUrl} (board ${cfg.boardId})...`);
76
+
77
+ // No dedicated "list contexts" endpoint — MODEL_CONTEXT nodes are the contexts,
78
+ // same as how the connect skill lists CHAPTER nodes for its health check.
79
+ const contextNodes = await fetchJson(
80
+ `${baseUrl}/api/org/${cfg.organizationId}/boards/${cfg.boardId}/nodes?type=MODEL_CONTEXT`,
81
+ 'nodes?type=MODEL_CONTEXT',
82
+ );
83
+ if (!contextNodes?.length) {
84
+ console.log('ℹ️ No contexts found on this board.');
85
+ return;
86
+ }
87
+
88
+ // /slicedata (buildSliceData) is per-context and returns full slice detail —
89
+ // commands/events/readmodels/screens/processors/specifications/comments — unlike
90
+ // the lightweight /slicedata/slices summary. There's no "all contexts in one
91
+ // call" variant, so fetch each context's full data and merge client-side.
92
+ const allSlices = [];
93
+ for (const node of contextNodes) {
94
+ const contextName = node.meta?.title ?? node.node?.data?.title ?? '';
95
+ // contextId is the normal path (we already have the node id); contextName is
96
+ // the fallback the endpoint itself supports when an id can't be resolved.
97
+ const contextQuery = node.id ? `contextId=${encodeURIComponent(node.id)}` : `contextName=${encodeURIComponent(contextName)}`;
98
+ const { slices } = await fetchJson(
99
+ `${baseUrl}/api/org/${cfg.organizationId}/boards/${cfg.boardId}/slicedata?${contextQuery}`,
100
+ `slicedata?${contextQuery}`,
101
+ );
102
+ allSlices.push(...slices);
103
+ }
104
+
105
+ if (!allSlices.length) {
106
+ console.log('ℹ️ No slices found on this board.');
107
+ return;
108
+ }
109
+
110
+ const SLICES_DIR = join(kitDir, '.slices');
111
+ const contextNames = new Set();
112
+
113
+ for (const slice of allSlices) {
114
+ // buildSliceData names this field `context`, not `contextName` (that's the
115
+ // /slicedata/slices summary endpoint's field) — read the one this endpoint sends.
116
+ const contextName = slice.context || 'default';
117
+ contextNames.add(contextName);
118
+ const contextSlug = slugify(contextName) || 'default';
119
+ const baseFolder = join(SLICES_DIR, contextSlug);
120
+ const sliceFolder = sliceFolderName(slice.title);
121
+ mkdirSync(join(baseFolder, sliceFolder), { recursive: true });
122
+
123
+ const sliceData = { ...slice };
124
+ delete sliceData.index;
125
+ writeFileSync(join(baseFolder, sliceFolder, 'slice.json'), JSON.stringify(sliceData, null, 2));
126
+ writeFileSync(join(baseFolder, 'context.json'), JSON.stringify({ name: contextName }, null, 2));
127
+
128
+ const indexFile = join(baseFolder, 'index.json');
129
+ const sliceIndices = readJsonSafe(indexFile);
130
+ if (!Array.isArray(sliceIndices.slices)) sliceIndices.slices = [];
131
+
132
+ const entry = {
133
+ id: slice.id,
134
+ slice: slice.title,
135
+ contextName,
136
+ contextSlug,
137
+ folder: sliceFolder,
138
+ status: slice.status,
139
+ definition: slice,
140
+ };
141
+
142
+ const existingIdx = sliceIndices.slices.findIndex((it) => it.id === slice.id);
143
+ if (existingIdx === -1) {
144
+ sliceIndices.slices.push(entry);
145
+ } else {
146
+ // Preserve `assigned` — it's local agent-claim state, not something the board tracks.
147
+ sliceIndices.slices[existingIdx] = { ...entry, assigned: sliceIndices.slices[existingIdx].assigned };
148
+ }
149
+ writeFileSync(indexFile, JSON.stringify(sliceIndices, null, 2));
150
+ }
151
+
152
+ // A single shared current_context.json only makes sense when everything fetched
153
+ // belongs to one context — with several, any one choice would be arbitrary, so
154
+ // leave whatever `listen`/a prior fetch already wrote there untouched.
155
+ if (contextNames.size === 1) {
156
+ writeFileSync(join(SLICES_DIR, 'current_context.json'), JSON.stringify({ name: [...contextNames][0] }, null, 2));
157
+ }
158
+
159
+ console.log(`✅ Fetched ${allSlices.length} slice${allSlices.length === 1 ? '' : 's'} across ${contextNames.size} context${contextNames.size === 1 ? '' : 's'} → ${relative(cwd, SLICES_DIR)}/`);
160
+
161
+ // --slice-id/--slice-title mirror load-slice's Step 4/5 — fetch+persist everything
162
+ // regardless, then just report the one the caller asked about.
163
+ if (opts.sliceId || opts.sliceTitle) {
164
+ const match = opts.sliceId
165
+ ? allSlices.find((s) => s.id === opts.sliceId)
166
+ : allSlices.find((s) => (s.title ?? '').toLowerCase() === opts.sliceTitle.toLowerCase());
167
+
168
+ if (!match) {
169
+ console.error(`\n❌ No slice found matching ${opts.sliceId ? `id "${opts.sliceId}"` : `title "${opts.sliceTitle}"`}. Available titles:`);
170
+ allSlices.forEach((s) => console.error(` - ${s.title}`));
171
+ process.exit(1);
172
+ }
173
+
174
+ const contextSlug = slugify(match.context || 'default') || 'default';
175
+ const sliceFolder = sliceFolderName(match.title);
176
+ console.log('\nRequested slice:');
177
+ console.log(` Title: ${match.title}`);
178
+ console.log(` ID: ${match.id}`);
179
+ console.log(` Status: ${match.status}`);
180
+ console.log(` Folder: ${relative(cwd, join(SLICES_DIR, contextSlug, sliceFolder, 'slice.json'))}`);
181
+ }
182
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eventmodelers/cli",
3
- "version": "0.0.23",
3
+ "version": "0.0.25",
4
4
  "description": "Eventmodelers CLI — real-time Claude agent + skills for Claude Code, for any stack (Node, Supabase, Axon, Cratis, or modeling-only)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "files": [
10
10
  "cli.js",
11
+ "lib",
11
12
  "shared",
12
13
  "stacks",
13
14
  "README.md"