@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 +5 -0
- package/cli.js +56 -0
- package/lib/fetch.js +182 -0
- package/package.json +2 -1
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.
|
|
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"
|