@eventmodelers/cli 1.0.6 → 1.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.
package/cli.js CHANGED
@@ -1430,7 +1430,9 @@ async function runModeling(kitDir, projectDir, verbose = false) {
1430
1430
  log(`channel "${channelName}": ${status}`);
1431
1431
  if (status === 'SUBSCRIBED') drain().catch((err) => log(`initial drain error: ${err.message}`));
1432
1432
  },
1433
- );
1433
+ ).catch((err) => {
1434
+ log(`realtime subscribe failed, prompts won't be pushed live: ${err.message}`);
1435
+ });
1434
1436
 
1435
1437
  setInterval(async () => {
1436
1438
  try {
@@ -1930,9 +1932,10 @@ program
1930
1932
  .command('fetch')
1931
1933
  .description('Pull full slice detail for one context on the board via the slicedata API and write it into .slices/ — the pull-based counterpart to `listen`, without screen images')
1932
1934
  .requiredOption('--context <name>', 'Name of the MODEL_CONTEXT to fetch')
1933
- .option('--slice-id <id>', 'After fetching, print just the slice with this id')
1934
- .option('--slice-title <title>', 'After fetching, print just the slice with this title (case-insensitive)')
1935
- .option('--spec-kitty', "After fetching, also restate this context as a Spec Kitty mission brief (.kittify/mission-brief.md via `spec-kitty intake`) — deterministic, no LLM call, no mission/spec.md/tasks created. Run `/spec-kitty.specify` afterward to turn the brief into a mission. Requires `spec-kitty init` to already be set up in this project (see lib/adapters/spec-kitty-adapter.js). One-shot: does not start a loop.")
1935
+ .option('--format <format>', 'Output format: json (default, builds the full .slices/ folder structure), yaml, textual, toon, emlang, or esdm (each of these five is dumped to a single .slices/<context>/slicedata.<ext> file instead)', 'json')
1936
+ .option('--slice-id <id>', 'After fetching, print just the slice with this id (requires --format json)')
1937
+ .option('--slice-title <title>', 'After fetching, print just the slice with this title, case-insensitive (requires --format json)')
1938
+ .option('--spec-kitty', "After fetching, also restate this context as a Spec Kitty mission brief (.kittify/mission-brief.md via `spec-kitty intake`) — deterministic, no LLM call, no mission/spec.md/tasks created. Run `/spec-kitty.specify` afterward to turn the brief into a mission. Requires `spec-kitty init` to already be set up in this project (see lib/adapters/spec-kitty-adapter.js) and --format json. One-shot: does not start a loop.")
1936
1939
  .action(async (opts, command) => {
1937
1940
  const cwd = process.cwd();
1938
1941
  const kitDir = findInstalledKitDir(cwd);
package/lib/fetch.js CHANGED
@@ -17,6 +17,14 @@ export class FetchAuthError extends Error {
17
17
 
18
18
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
19
19
 
20
+ // Mirrors the server's exporter registry (backend/src/slices/change/slicedata/exporters/
21
+ // ExporterRegistry.ts) — json is the canonical shape (drives the full .slices/ folder
22
+ // structure below); every other format is a transform of it and gets dumped as a single
23
+ // file instead, so its extension reflects the transform's actual output, not its name
24
+ // (textual is JSON-wrapped; emlang/esdm are YAML documents; toon is its own thing).
25
+ const SUPPORTED_FORMATS = ['json', 'yaml', 'textual', 'toon', 'emlang', 'esdm'];
26
+ const FORMAT_EXTENSIONS = { yaml: 'yaml', textual: 'json', toon: 'toon', emlang: 'yaml', esdm: 'yaml' };
27
+
20
28
  // `--context` accepts any of: a MODEL_CONTEXT name or id, or a timeline (CHAPTER) name or id.
21
29
  // /slicedata's contextId/contextName params now both resolve against MODEL_CONTEXT nodes first,
22
30
  // then timelines (id matched exactly, name case-insensitively) — including a timeline with no
@@ -72,7 +80,7 @@ export async function runFetch({ cwd, kitDir, cfg, opts = {} }) {
72
80
  // never "board not found". Only 401/403 are credential problems worth the
73
81
  // reconfigure-and-retry dance in cli.js; 404 gets reported and the process exits,
74
82
  // same as any other non-auth error.
75
- async function fetchJson(url, what, { allow404 = false } = {}) {
83
+ async function fetchResponse(url, what, { allow404 = false } = {}) {
76
84
  let res;
77
85
  try {
78
86
  res = await fetch(url, { headers });
@@ -92,7 +100,19 @@ export async function runFetch({ cwd, kitDir, cfg, opts = {} }) {
92
100
  console.error(`❌ ${what}: HTTP ${res.status}`);
93
101
  process.exit(1);
94
102
  }
95
- return res.json();
103
+ return res;
104
+ }
105
+
106
+ async function fetchJson(url, what, options) {
107
+ const res = await fetchResponse(url, what, options);
108
+ return res ? res.json() : res;
109
+ }
110
+
111
+ // Non-json formats aren't a {slices: [...]} payload — just the exporter's raw output
112
+ // (a YAML doc, a TOON encoding, ...) — so it's written straight to disk, not parsed.
113
+ async function fetchText(url, what, options) {
114
+ const res = await fetchResponse(url, what, options);
115
+ return res ? res.text() : res;
96
116
  }
97
117
 
98
118
  // Falls back to cwd when no kit is installed — fetch doesn't need kit-specific
@@ -100,6 +120,17 @@ export async function runFetch({ cwd, kitDir, cfg, opts = {} }) {
100
120
  const SLICES_DIR = join(kitDir || cwd, '.slices');
101
121
 
102
122
  const contextInput = opts.context;
123
+ const format = (opts.format || 'json').toLowerCase();
124
+ if (!SUPPORTED_FORMATS.includes(format)) {
125
+ console.error(`❌ Unsupported format "${format}". Supported: ${SUPPORTED_FORMATS.join(', ')}`);
126
+ process.exit(1);
127
+ }
128
+ // --slice-id/--slice-title/--spec-kitty all depend on the parsed {slices: [...]} list
129
+ // that only the json format produces — fail fast instead of silently ignoring them.
130
+ if (format !== 'json' && (opts.sliceId || opts.sliceTitle || opts.specKitty)) {
131
+ console.error('❌ --slice-id, --slice-title, and --spec-kitty require --format json (the default).');
132
+ process.exit(1);
133
+ }
103
134
 
104
135
  console.log(`▶ Fetching context "${contextInput}" from ${baseUrl} (board ${cfg.boardId})...`);
105
136
 
@@ -112,10 +143,25 @@ export async function runFetch({ cwd, kitDir, cfg, opts = {} }) {
112
143
  const contextQuery = UUID_RE.test(contextInput)
113
144
  ? `contextId=${encodeURIComponent(contextInput)}`
114
145
  : `contextName=${encodeURIComponent(contextInput)}`;
115
- const payload = await fetchJson(
116
- `${baseUrl}/api/org/${cfg.organizationId}/boards/${cfg.boardId}/slicedata?${contextQuery}`,
117
- `slicedata?${contextQuery}`,
118
- );
146
+ const url = `${baseUrl}/api/org/${cfg.organizationId}/boards/${cfg.boardId}/slicedata?${contextQuery}&format=${format}`;
147
+
148
+ // Only json builds the full .slices/<context>/<slice>/slice.json folder structure —
149
+ // every other format has no guaranteed {slices: [...]} shape to walk (it's the
150
+ // exporter's raw output: a YAML doc, a TOON encoding, ...), so it's just dumped
151
+ // as a single file. The resolved context name isn't known without parsing JSON,
152
+ // so the folder is slugified from the raw --context input instead.
153
+ if (format !== 'json') {
154
+ const body = await fetchText(url, `slicedata?${contextQuery}&format=${format}`);
155
+ const contextSlug = slugify(contextInput) || 'default';
156
+ const baseFolder = join(SLICES_DIR, contextSlug);
157
+ mkdirSync(baseFolder, { recursive: true });
158
+ const outFile = join(baseFolder, `slicedata.${FORMAT_EXTENSIONS[format]}`);
159
+ writeFileSync(outFile, body);
160
+ console.log(`✅ Fetched context "${contextInput}" as ${format} → ${relative(cwd, outFile)}`);
161
+ return;
162
+ }
163
+
164
+ const payload = await fetchJson(url, `slicedata?${contextQuery}&format=${format}`);
119
165
  const { slices: allSlices } = payload;
120
166
  const displayContext = allSlices[0]?.context || contextInput;
121
167
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eventmodelers/cli",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
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": {
@@ -7,6 +7,15 @@
7
7
 
8
8
  const REALTIME_EVENTS_COLLECTION = 'realtime_events';
9
9
 
10
+ // Same backoff steps the PocketBase SDK uses for its own reconnects (see
11
+ // predefinedReconnectIntervals in the SDK) — reused here because the SDK only
12
+ // applies that backoff to a connection that drops *after* it was established.
13
+ // A failed first handshake (e.g. "Invalid realtime client" from the initial
14
+ // GET /api/realtime and the follow-up subscribe POST landing on different
15
+ // backend instances) rejects immediately with no retry at all, so we retry it
16
+ // ourselves.
17
+ const RECONNECT_INTERVALS_MS = [200, 300, 500, 1000, 1200, 1500, 2000];
18
+
10
19
  export async function createPocketBaseRealtimeAdapter(cfg, initialToken) {
11
20
  const { EventSource } = await import('eventsource');
12
21
  if (!globalThis.EventSource) globalThis.EventSource = EventSource; // PocketBase's SDK assumes a browser-style global
@@ -16,11 +25,20 @@ export async function createPocketBaseRealtimeAdapter(cfg, initialToken) {
16
25
 
17
26
  return {
18
27
  async subscribe(topic, handlers, onStatus) {
19
- await pb.collection(REALTIME_EVENTS_COLLECTION).subscribe('*', (e) => {
20
- if (e.action !== 'create' || e.record.topic !== topic) return;
21
- handlers[e.record.event]?.(e.record.payload);
22
- });
23
- onStatus?.('SUBSCRIBED');
28
+ for (let attempt = 0; ; attempt++) {
29
+ try {
30
+ await pb.collection(REALTIME_EVENTS_COLLECTION).subscribe('*', (e) => {
31
+ if (e.action !== 'create' || e.record.topic !== topic) return;
32
+ handlers[e.record.event]?.(e.record.payload);
33
+ });
34
+ onStatus?.('SUBSCRIBED');
35
+ return;
36
+ } catch (err) {
37
+ if (attempt >= RECONNECT_INTERVALS_MS.length) throw err;
38
+ onStatus?.(`RECONNECTING (attempt ${attempt + 1}/${RECONNECT_INTERVALS_MS.length}): ${err.message}`);
39
+ await new Promise((resolve) => setTimeout(resolve, RECONNECT_INTERVALS_MS[attempt]));
40
+ }
41
+ }
24
42
  },
25
43
  setAuth(token) {
26
44
  pb.authStore.save(token, null);